From 179d74a97c042d7d35a5d3ed052c0f3d98766316 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:53:21 +0000 Subject: [PATCH 1/5] feat(analytics): set detected email providers on the person The codebase scan already finds email-sending services such as Resend. Set them on the person as wizard_detected_email_providers and wizard_detected_email_provider_at, through $set on the `warehouse sources detected` event. A $set captured before login is set again at identify, so it reaches the person who uses the PostHog app. Co-Authored-By: Claude Opus 5.5 --- src/services/codebase-scan/email-providers.ts | 45 ++++++++++++++++ .../detection/__tests__/integration.test.ts | 52 +++++++++++++++++++ src/services/detection/integration.ts | 4 ++ .../__tests__/helpers/tool-catalog.no-jest.ts | 6 +++ src/shared/utils/__tests__/analytics.test.ts | 19 +++++++ src/shared/utils/analytics.ts | 13 ++++- 6 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/services/codebase-scan/email-providers.ts diff --git a/src/services/codebase-scan/email-providers.ts b/src/services/codebase-scan/email-providers.ts new file mode 100644 index 000000000..ad0bff05a --- /dev/null +++ b/src/services/codebase-scan/email-providers.ts @@ -0,0 +1,45 @@ +/** Person properties naming the email-sending services, by tool catalog kind, that a scan found. */ + +import type { CodebaseScan } from './scan'; + +const EMAIL_PROVIDER_KINDS: ReadonlySet = new Set([ + 'AwsSes', + 'BigMailer', + 'Brevo', + 'Elasticemail', + 'EmailOctopus', + 'Flexmail', + 'Loops', + 'Mailchimp', + 'MailerLite', + 'MailerSend', + 'Mailgun', + 'Mailjet', + 'Plunk', + 'Postmark', + 'Resend', + 'SendGrid', + 'Shipmail', + 'Smaily', + 'SparkPost', + 'Tinyemail', +]); + +export type EmailProviderPersonProperties = { + wizard_detected_email_providers: string[]; + wizard_detected_email_provider_at: string; +}; + +/** Undefined when the scan found no email provider, so a person keeps what an earlier run set. */ +export function emailProviderPersonProperties( + scan: CodebaseScan, +): EmailProviderPersonProperties | undefined { + const providers = scan.tools + .map((tool) => tool.kind) + .filter((kind) => EMAIL_PROVIDER_KINDS.has(kind)); + if (providers.length === 0) return undefined; + return { + wizard_detected_email_providers: providers, + wizard_detected_email_provider_at: scan.scannedAt, + }; +} diff --git a/src/services/detection/__tests__/integration.test.ts b/src/services/detection/__tests__/integration.test.ts index 099fd768c..a22194fa4 100644 --- a/src/services/detection/__tests__/integration.test.ts +++ b/src/services/detection/__tests__/integration.test.ts @@ -334,6 +334,58 @@ describe('reportWarehouseSourcesDetected', () => { }); }); +describe('detected email providers reach the person', () => { + let tmpDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-26T09:30:00.000Z')); + tmpDir = makeTmpDir(); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(tmpDir); + }); + + async function reportProject( + dependencies: Record, + ): Promise { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ dependencies }), + ); + const session = buildSession({ installDir: tmpDir }); + session.scanConsent = ScanConsent.Granted; + await detect(makeCtx(session)); + reportWarehouseSourcesDetected(session); + } + + it('sets the providers and when they were found on the person', async () => { + await reportProject({ resend: '^4.0.0', stripe: '^14.0.0' }); + + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'warehouse sources detected', + expect.objectContaining({ + $set: { + wizard_detected_email_providers: ['Resend'], + wizard_detected_email_provider_at: '2026-10-26T09:30:00.000Z', + }, + }), + ); + }); + + it('leaves the person alone when no email provider is found', async () => { + await reportProject({ stripe: '^14.0.0' }); + + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'warehouse sources detected', + expect.not.objectContaining({ $set: expect.anything() }), + ); + }); +}); + describe('only a scripted CI run starts with consent already granted', () => { it('ci: true builds scanConsent as granted', () => { const session = buildSession({ installDir: '/tmp/app', ci: true }); diff --git a/src/services/detection/integration.ts b/src/services/detection/integration.ts index ee1e2a3d6..8801f3142 100644 --- a/src/services/detection/integration.ts +++ b/src/services/detection/integration.ts @@ -25,6 +25,7 @@ import { getCodebaseScan, scannedWarehouseSources, } from '../codebase-scan/scan'; +import { emailProviderPersonProperties } from '../codebase-scan/email-providers'; import { DETECTED_WAREHOUSE_SOURCES_KEY, getDetectedWarehouseSources, @@ -174,6 +175,8 @@ export function reportWarehouseSourcesDetected( // Same consent gate as `sessionProperties`. PostHog derives AI SDK // detection from these and the source kinds. const discoveredFeatures = reportableDiscoveredFeatures(session); + const scan = getCodebaseScan(session); + const emailProviders = scan && emailProviderPersonProperties(scan); analytics.wizardCapture('warehouse sources detected', { warehouse_source_count: sources.length, warehouse_source_kinds: sources.map((s) => s.kind), @@ -181,6 +184,7 @@ export function reportWarehouseSourcesDetected( ...(discoveredFeatures ? { discovered_features: discoveredFeatures } : {}), + ...(emailProviders ? { $set: emailProviders } : {}), }); if (sources.length > 0) { diff --git a/src/services/warehouse-sources/__tests__/helpers/tool-catalog.no-jest.ts b/src/services/warehouse-sources/__tests__/helpers/tool-catalog.no-jest.ts index 0aa4006b3..c56ca377a 100644 --- a/src/services/warehouse-sources/__tests__/helpers/tool-catalog.no-jest.ts +++ b/src/services/warehouse-sources/__tests__/helpers/tool-catalog.no-jest.ts @@ -81,6 +81,12 @@ export const FIXTURE_CATALOG: ToolCatalog = { packages: { pypi: ['mixpanel'], 'not-an-ecosystem-yet': ['mixpanel'] }, }, }), + tool('Resend', { + signals: { + env_key_patterns: ['^RESEND_API_KEY$'], + packages: { npm: ['resend'] }, + }, + }), tool('Sentry', { signals: { packages: { npm: ['@sentry/node'] } }, }), diff --git a/src/shared/utils/__tests__/analytics.test.ts b/src/shared/utils/__tests__/analytics.test.ts index a2f439780..037911cce 100644 --- a/src/shared/utils/__tests__/analytics.test.ts +++ b/src/shared/utils/__tests__/analytics.test.ts @@ -503,6 +503,25 @@ describe('Analytics', () => { ); }); + it('sets a person property captured before login on the identified person', () => { + analytics.capture('wizard: warehouse sources detected', { + $set: { wizard_detected_email_providers: ['Resend'] }, + }); + + analytics.identifyUser(user); + + expect(mockPostHogInstance.identify).toHaveBeenCalledWith({ + distinctId: 'user-123', + properties: { + $set: { + wizard_detected_email_providers: ['Resend'], + email: 'v@posthog.com', + name: 'Vincent', + }, + }, + }); + }); + it('runs once per user — re-login does not re-identify or re-merge', () => { analytics.identifyUser(user); analytics.identifyUser(user); diff --git a/src/shared/utils/analytics.ts b/src/shared/utils/analytics.ts index 13267ee12..e3dfc0228 100644 --- a/src/shared/utils/analytics.ts +++ b/src/shared/utils/analytics.ts @@ -126,6 +126,7 @@ export class Analytics { private activeFlagPayloads: Record = {}; private groups: Record = {}; private personProperties: Record = {}; + private anonymousPersonSet: Record = {}; private terminalEventSent = false; constructor() { @@ -235,7 +236,10 @@ export class Analytics { .trim(); if (name) props.name = name; this.personProperties = props; - this.client.identify({ distinctId, properties: { $set: props } }); + this.client.identify({ + distinctId, + properties: { $set: { ...this.anonymousPersonSet, ...props } }, + }); this.client.alias({ distinctId, alias: this.anonymousId, @@ -289,6 +293,7 @@ export class Analytics { } capture(eventName: string, properties?: Record) { + if (this.distinctId === undefined) this.keepForLogin(properties?.$set); this.client.capture({ distinctId: this.distinctId ?? this.anonymousId, event: eventName, @@ -300,6 +305,12 @@ export class Analytics { }); } + // The alias merge keeps the identified person's older value, so login sets it again. + private keepForLogin(personSet: unknown) { + if (typeof personSet !== 'object' || personSet === null) return; + this.anonymousPersonSet = { ...this.anonymousPersonSet, ...personSet }; + } + groupIdentify( groupType: string, groupKey: string, From ef51229d6a32456556655961eb56f5025ab54859 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:13:58 +0000 Subject: [PATCH 2/5] chore(analytics): drop comments from the email provider change Co-Authored-By: Claude Opus 5.5 --- src/services/codebase-scan/email-providers.ts | 3 --- src/shared/utils/analytics.ts | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/services/codebase-scan/email-providers.ts b/src/services/codebase-scan/email-providers.ts index ad0bff05a..5c8a495b6 100644 --- a/src/services/codebase-scan/email-providers.ts +++ b/src/services/codebase-scan/email-providers.ts @@ -1,5 +1,3 @@ -/** Person properties naming the email-sending services, by tool catalog kind, that a scan found. */ - import type { CodebaseScan } from './scan'; const EMAIL_PROVIDER_KINDS: ReadonlySet = new Set([ @@ -30,7 +28,6 @@ export type EmailProviderPersonProperties = { wizard_detected_email_provider_at: string; }; -/** Undefined when the scan found no email provider, so a person keeps what an earlier run set. */ export function emailProviderPersonProperties( scan: CodebaseScan, ): EmailProviderPersonProperties | undefined { diff --git a/src/shared/utils/analytics.ts b/src/shared/utils/analytics.ts index e3dfc0228..83ff15ba7 100644 --- a/src/shared/utils/analytics.ts +++ b/src/shared/utils/analytics.ts @@ -293,7 +293,8 @@ export class Analytics { } capture(eventName: string, properties?: Record) { - if (this.distinctId === undefined) this.keepForLogin(properties?.$set); + if (this.distinctId === undefined) + this.rememberSetForIdentify(properties?.$set); this.client.capture({ distinctId: this.distinctId ?? this.anonymousId, event: eventName, @@ -305,8 +306,7 @@ export class Analytics { }); } - // The alias merge keeps the identified person's older value, so login sets it again. - private keepForLogin(personSet: unknown) { + private rememberSetForIdentify(personSet: unknown) { if (typeof personSet !== 'object' || personSet === null) return; this.anonymousPersonSet = { ...this.anonymousPersonSet, ...personSet }; } From 288c11c8dc405fae1a35d5dec8ff25068cd90e47 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:04:33 +0000 Subject: [PATCH 3/5] fix(analytics): set detected email providers from the scan report Set the person properties when the scan is shared after login, so CI, headless, warehouse and self-driving runs set them too, and drop the pre-login replay in Analytics. Count Campaign Monitor, ConvertKit, beehiiv and Drip as email providers. Co-Authored-By: Claude Opus 5.5 --- .../codebase-scan/__tests__/report.test.ts | 53 +++++++++++++++++++ src/services/codebase-scan/email-providers.ts | 4 ++ src/services/codebase-scan/report.ts | 11 +++- .../detection/__tests__/integration.test.ts | 52 ------------------ src/services/detection/integration.ts | 4 -- src/shared/utils/__tests__/analytics.test.ts | 35 ++++++------ src/shared/utils/analytics.ts | 16 +++--- 7 files changed, 89 insertions(+), 86 deletions(-) diff --git a/src/services/codebase-scan/__tests__/report.test.ts b/src/services/codebase-scan/__tests__/report.test.ts index 0de2af667..aa19e8311 100644 --- a/src/services/codebase-scan/__tests__/report.test.ts +++ b/src/services/codebase-scan/__tests__/report.test.ts @@ -6,6 +6,7 @@ vi.mock(import('@utils/analytics'), () => ({ analytics: { wizardCapture: vi.fn(), setTag: vi.fn(), + setPersonProperties: vi.fn(), captureException: vi.fn(), runId: 'run-123', } as never, @@ -162,6 +163,58 @@ describe('reportCodebaseScan', () => { }); }); + describe('detected email providers', () => { + beforeEach(() => { + vi.mocked(analytics.setPersonProperties).mockClear(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-26T09:30:00.000Z')); + }); + afterEach(() => vi.useRealTimers()); + + async function reportProjectUsing( + dependencies: Record, + ): Promise { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ dependencies }), + ); + const store = await ciStore(); + store.setCredentials(CREDENTIALS); + reportCodebaseScan(store); + reportCodebaseScan(store); + } + + it('sets the providers and when they were found on the logged-in person, once', async () => { + await reportProjectUsing({ resend: '^4.0.0', stripe: '^14.0.0' }); + + expect(analytics.setPersonProperties).toHaveBeenCalledExactlyOnceWith({ + wizard_detected_email_providers: ['Resend'], + wizard_detected_email_provider_at: '2026-10-26T09:30:00.000Z', + }); + }); + + it('leaves the person alone when no email provider is found', async () => { + await reportProjectUsing({ stripe: '^14.0.0' }); + + expect(analytics.setPersonProperties).not.toHaveBeenCalled(); + }); + + it('leaves the person alone when the user declines sharing', async () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ dependencies: { resend: '^4.0.0' } }), + ); + const store = new SessionStore(buildSession({ installDir: tmpDir })); + await scanCodebaseFor(PROGRAM, store.readyContext()); + store.declineSharing(); + store.setCredentials(CREDENTIALS); + + reportCodebaseScan(store); + + expect(analytics.setPersonProperties).not.toHaveBeenCalled(); + }); + }); + describe('scan tags', () => { const tagged = () => vi.mocked(analytics.setTag).mock.calls; diff --git a/src/services/codebase-scan/email-providers.ts b/src/services/codebase-scan/email-providers.ts index 5c8a495b6..67a5efa39 100644 --- a/src/services/codebase-scan/email-providers.ts +++ b/src/services/codebase-scan/email-providers.ts @@ -2,8 +2,12 @@ import type { CodebaseScan } from './scan'; const EMAIL_PROVIDER_KINDS: ReadonlySet = new Set([ 'AwsSes', + 'Beehiiv', 'BigMailer', 'Brevo', + 'CampaignMonitor', + 'ConvertKit', + 'Drip', 'Elasticemail', 'EmailOctopus', 'Flexmail', diff --git a/src/services/codebase-scan/report.ts b/src/services/codebase-scan/report.ts index 16011f56f..085cd057b 100644 --- a/src/services/codebase-scan/report.ts +++ b/src/services/codebase-scan/report.ts @@ -7,12 +7,15 @@ * because either can happen first. Each part latches, so a run reports once: * - the `warehouse sources detected` event, which needs no login * - the scan, which needs the login for its fingerprint. This hands it to - * the task stream, which attaches it to the run's wizard session. + * the task stream, which attaches it to the run's wizard session, and + * sets the email providers it found on the logged-in person. */ import { mayReportScanResults } from '@shared/run-state'; import { VERSION } from '@shared/version'; +import { analytics } from '@utils/analytics'; import type { WizardSession } from '../session/wizard-session'; +import { emailProviderPersonProperties } from './email-providers'; import { workspaceFingerprint } from './fingerprint'; import { getCodebaseScan, type CodebaseScan } from './scan'; import { applyScanTags } from './tags'; @@ -60,5 +63,11 @@ export function reportCodebaseScan(store: ScanReportStore): void { // A failed scan found nothing because it could not look. Posting its empty // list would read as "every tool was removed". if (!scan || scan.failed) return; + setEmailProvidersOnPerson(scan); store.shareCodebaseScan(buildScanReport(scan, credentials.projectId)); } + +function setEmailProvidersOnPerson(scan: CodebaseScan): void { + const emailProviders = emailProviderPersonProperties(scan); + if (emailProviders) analytics.setPersonProperties(emailProviders); +} diff --git a/src/services/detection/__tests__/integration.test.ts b/src/services/detection/__tests__/integration.test.ts index a22194fa4..099fd768c 100644 --- a/src/services/detection/__tests__/integration.test.ts +++ b/src/services/detection/__tests__/integration.test.ts @@ -334,58 +334,6 @@ describe('reportWarehouseSourcesDetected', () => { }); }); -describe('detected email providers reach the person', () => { - let tmpDir: string; - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers({ toFake: ['Date'] }); - vi.setSystemTime(new Date('2026-10-26T09:30:00.000Z')); - tmpDir = makeTmpDir(); - }); - - afterEach(() => { - vi.useRealTimers(); - cleanup(tmpDir); - }); - - async function reportProject( - dependencies: Record, - ): Promise { - fs.writeFileSync( - path.join(tmpDir, 'package.json'), - JSON.stringify({ dependencies }), - ); - const session = buildSession({ installDir: tmpDir }); - session.scanConsent = ScanConsent.Granted; - await detect(makeCtx(session)); - reportWarehouseSourcesDetected(session); - } - - it('sets the providers and when they were found on the person', async () => { - await reportProject({ resend: '^4.0.0', stripe: '^14.0.0' }); - - expect(analytics.wizardCapture).toHaveBeenCalledWith( - 'warehouse sources detected', - expect.objectContaining({ - $set: { - wizard_detected_email_providers: ['Resend'], - wizard_detected_email_provider_at: '2026-10-26T09:30:00.000Z', - }, - }), - ); - }); - - it('leaves the person alone when no email provider is found', async () => { - await reportProject({ stripe: '^14.0.0' }); - - expect(analytics.wizardCapture).toHaveBeenCalledWith( - 'warehouse sources detected', - expect.not.objectContaining({ $set: expect.anything() }), - ); - }); -}); - describe('only a scripted CI run starts with consent already granted', () => { it('ci: true builds scanConsent as granted', () => { const session = buildSession({ installDir: '/tmp/app', ci: true }); diff --git a/src/services/detection/integration.ts b/src/services/detection/integration.ts index 8801f3142..ee1e2a3d6 100644 --- a/src/services/detection/integration.ts +++ b/src/services/detection/integration.ts @@ -25,7 +25,6 @@ import { getCodebaseScan, scannedWarehouseSources, } from '../codebase-scan/scan'; -import { emailProviderPersonProperties } from '../codebase-scan/email-providers'; import { DETECTED_WAREHOUSE_SOURCES_KEY, getDetectedWarehouseSources, @@ -175,8 +174,6 @@ export function reportWarehouseSourcesDetected( // Same consent gate as `sessionProperties`. PostHog derives AI SDK // detection from these and the source kinds. const discoveredFeatures = reportableDiscoveredFeatures(session); - const scan = getCodebaseScan(session); - const emailProviders = scan && emailProviderPersonProperties(scan); analytics.wizardCapture('warehouse sources detected', { warehouse_source_count: sources.length, warehouse_source_kinds: sources.map((s) => s.kind), @@ -184,7 +181,6 @@ export function reportWarehouseSourcesDetected( ...(discoveredFeatures ? { discovered_features: discoveredFeatures } : {}), - ...(emailProviders ? { $set: emailProviders } : {}), }); if (sources.length > 0) { diff --git a/src/shared/utils/__tests__/analytics.test.ts b/src/shared/utils/__tests__/analytics.test.ts index 037911cce..44be51216 100644 --- a/src/shared/utils/__tests__/analytics.test.ts +++ b/src/shared/utils/__tests__/analytics.test.ts @@ -67,6 +67,7 @@ describe('Analytics', () => { alias: vi.fn(), identify: vi.fn(), groupIdentify: vi.fn(), + setPersonProperties: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined), } as any; @@ -503,25 +504,6 @@ describe('Analytics', () => { ); }); - it('sets a person property captured before login on the identified person', () => { - analytics.capture('wizard: warehouse sources detected', { - $set: { wizard_detected_email_providers: ['Resend'] }, - }); - - analytics.identifyUser(user); - - expect(mockPostHogInstance.identify).toHaveBeenCalledWith({ - distinctId: 'user-123', - properties: { - $set: { - wizard_detected_email_providers: ['Resend'], - email: 'v@posthog.com', - name: 'Vincent', - }, - }, - }); - }); - it('runs once per user — re-login does not re-identify or re-merge', () => { analytics.identifyUser(user); analytics.identifyUser(user); @@ -774,6 +756,21 @@ describe('Analytics', () => { }); }); + describe('setPersonProperties', () => { + it('sets the properties on the logged-in person', () => { + analytics.identifyUser({ distinct_id: 'user-123' } as ApiUser); + + analytics.setPersonProperties({ + wizard_detected_email_providers: ['Resend'], + }); + + expect(mockPostHogInstance.setPersonProperties).toHaveBeenCalledWith({ + distinctId: 'user-123', + properties: { wizard_detected_email_providers: ['Resend'] }, + }); + }); + }); + describe('integration with other methods', () => { it('should work correctly with setTag and captureException', () => { const error = new Error('Test error'); diff --git a/src/shared/utils/analytics.ts b/src/shared/utils/analytics.ts index 83ff15ba7..ce4d6982a 100644 --- a/src/shared/utils/analytics.ts +++ b/src/shared/utils/analytics.ts @@ -126,7 +126,6 @@ export class Analytics { private activeFlagPayloads: Record = {}; private groups: Record = {}; private personProperties: Record = {}; - private anonymousPersonSet: Record = {}; private terminalEventSent = false; constructor() { @@ -236,10 +235,7 @@ export class Analytics { .trim(); if (name) props.name = name; this.personProperties = props; - this.client.identify({ - distinctId, - properties: { $set: { ...this.anonymousPersonSet, ...props } }, - }); + this.client.identify({ distinctId, properties: { $set: props } }); this.client.alias({ distinctId, alias: this.anonymousId, @@ -293,8 +289,6 @@ export class Analytics { } capture(eventName: string, properties?: Record) { - if (this.distinctId === undefined) - this.rememberSetForIdentify(properties?.$set); this.client.capture({ distinctId: this.distinctId ?? this.anonymousId, event: eventName, @@ -306,9 +300,11 @@ export class Analytics { }); } - private rememberSetForIdentify(personSet: unknown) { - if (typeof personSet !== 'object' || personSet === null) return; - this.anonymousPersonSet = { ...this.anonymousPersonSet, ...personSet }; + setPersonProperties(properties: Record) { + this.client.setPersonProperties({ + distinctId: this.distinctId ?? this.anonymousId, + properties, + }); } groupIdentify( From f7587c88ddab9a35f9ab27723b1b4fdb0b290bcf Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:20:18 +0000 Subject: [PATCH 4/5] fix(auth): identify the account a signup creates Read the new account's user after provisioning and identify it, as the OAuth login does, so a signup run's events and the detected email providers land on its person. A failed read keeps the signup. Co-Authored-By: Claude Opus 5.5 --- src/tui/auth/__tests__/ci-region.test.ts | 47 ++++++++++++++++++++++++ src/tui/auth/project-data.ts | 15 ++++++++ 2 files changed, 62 insertions(+) diff --git a/src/tui/auth/__tests__/ci-region.test.ts b/src/tui/auth/__tests__/ci-region.test.ts index 9ad7dae83..4bfdde85b 100644 --- a/src/tui/auth/__tests__/ci-region.test.ts +++ b/src/tui/auth/__tests__/ci-region.test.ts @@ -3,6 +3,7 @@ import { detectRegion } from '@utils/urls'; import { fetchProjectData, fetchUserData } from '@shared/api'; import { performOAuthFlow } from '../oauth'; import { provisionNewAccount } from '@utils/provisioning'; +import { analytics } from '@utils/analytics'; import type { WizardStore } from '@tui/store'; import { WIZARD_OAUTH_SCOPES } from '@shared/constants'; import { CONNECT_SLACK_SCOPE_ADDITIONS } from '@shared/oauth-scopes'; @@ -176,3 +177,49 @@ describe('getOrAskForProjectData signup fallback', () => { ); }); }); + +describe('getOrAskForProjectData signup', () => { + const newUser = { distinct_id: 'new-user', role_at_organization: null }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(provisionNewAccount).mockResolvedValue({ + accessToken: 'pha_new', + refreshToken: 'phr_new', + expiresAt: 0, + oauthClientId: 'client', + projectApiKey: 'phc_new', + host: 'https://us.i.posthog.com', + personalApiKey: undefined, + projectId: '5', + accountId: 'account', + } as never); + }); + + function signUp() { + return getOrAskForProjectData({ + store, + signup: true, + email: 'dev@example.com', + }); + } + + it('identifies the new account, so the run lands on its person', async () => { + mockedFetchUser.mockResolvedValue(newUser); + + const result = await signUp(); + + expect(analytics.identifyUser).toHaveBeenCalledWith(newUser); + expect(result.user).toEqual(newUser); + }); + + it('still signs the new account in when its user cannot be read', async () => { + mockedFetchUser.mockRejectedValue(new Error('403')); + + const result = await signUp(); + + expect(analytics.identifyUser).not.toHaveBeenCalled(); + expect(result.accessToken).toBe('pha_new'); + expect(result.user).toBeNull(); + }); +}); diff --git a/src/tui/auth/project-data.ts b/src/tui/auth/project-data.ts index a280d23c4..7b3283071 100644 --- a/src/tui/auth/project-data.ts +++ b/src/tui/auth/project-data.ts @@ -324,6 +324,18 @@ async function askForWizardLogin(options: { return data; } +async function readSignedUpUser( + accessToken: string, + appHost: string, +): Promise { + try { + return await fetchUserData(accessToken, appHost); + } catch (error) { + analytics.captureException(error as Error, { step: 'wizard_signup' }); + return null; + } +} + async function askForProvisioningSignup( store: WizardStore, email?: string, @@ -362,6 +374,8 @@ async function askForProvisioningSignup( const host = HostResolution.fromApiHost(result.host, { localMcp }); analytics.setTag('provisioning-signup', true); + const user = await readSignedUpUser(result.accessToken, host.appHost); + if (user) analytics.identifyUser(user); return { accessToken: result.accessToken, @@ -372,6 +386,7 @@ async function askForProvisioningSignup( host, distinctId: email, projectId: parseInt(result.projectId, 10) || 0, + user, }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; From ae686b9153e5cea73fcd2234ebff2078320ba17c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:29:22 +0000 Subject: [PATCH 5/5] fix(analytics): set email providers only on a logged-in user A login without a user, such as a signup whose user could not be read, has no person to set them on. Co-Authored-By: Claude Opus 5.5 --- .../codebase-scan/__tests__/report.test.ts | 19 ++++++++++++++++++- src/services/codebase-scan/report.ts | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/services/codebase-scan/__tests__/report.test.ts b/src/services/codebase-scan/__tests__/report.test.ts index aa19e8311..461065a1c 100644 --- a/src/services/codebase-scan/__tests__/report.test.ts +++ b/src/services/codebase-scan/__tests__/report.test.ts @@ -34,7 +34,7 @@ import { loadSourceDetectors } from '@services/warehouse-sources/registry'; import { analytics } from '@utils/analytics'; import { SessionStore } from '@services/session/session-store'; import { buildSession } from '@services/session/wizard-session'; -import type { Credentials } from '@shared/api'; +import type { ApiUser, Credentials } from '@shared/api'; const CREDENTIALS = { accessToken: 'pha_test', @@ -43,6 +43,8 @@ const CREDENTIALS = { host: { apiHost: 'https://us.i.posthog.com' }, } as unknown as Credentials; +const USER = { distinct_id: 'user-1' } as ApiUser; + const PROGRAM = { id: 'posthog-integration', scansCodebase: true }; describe('reportCodebaseScan', () => { @@ -180,6 +182,7 @@ describe('reportCodebaseScan', () => { ); const store = await ciStore(); store.setCredentials(CREDENTIALS); + store.setApiUser(USER); reportCodebaseScan(store); reportCodebaseScan(store); } @@ -199,6 +202,19 @@ describe('reportCodebaseScan', () => { expect(analytics.setPersonProperties).not.toHaveBeenCalled(); }); + it('leaves the person alone on a login without a user to set it on', async () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ dependencies: { resend: '^4.0.0' } }), + ); + const store = await ciStore(); + store.setCredentials(CREDENTIALS); + + reportCodebaseScan(store); + + expect(analytics.setPersonProperties).not.toHaveBeenCalled(); + }); + it('leaves the person alone when the user declines sharing', async () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), @@ -208,6 +224,7 @@ describe('reportCodebaseScan', () => { await scanCodebaseFor(PROGRAM, store.readyContext()); store.declineSharing(); store.setCredentials(CREDENTIALS); + store.setApiUser(USER); reportCodebaseScan(store); diff --git a/src/services/codebase-scan/report.ts b/src/services/codebase-scan/report.ts index 085cd057b..f0ba8b3af 100644 --- a/src/services/codebase-scan/report.ts +++ b/src/services/codebase-scan/report.ts @@ -63,7 +63,7 @@ export function reportCodebaseScan(store: ScanReportStore): void { // A failed scan found nothing because it could not look. Posting its empty // list would read as "every tool was removed". if (!scan || scan.failed) return; - setEmailProvidersOnPerson(scan); + if (session.apiUser) setEmailProvidersOnPerson(scan); store.shareCodebaseScan(buildScanReport(scan, credentials.projectId)); }