diff --git a/src/services/codebase-scan/__tests__/report.test.ts b/src/services/codebase-scan/__tests__/report.test.ts index 0de2af667..461065a1c 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, @@ -33,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', @@ -42,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', () => { @@ -162,6 +165,73 @@ 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); + store.setApiUser(USER); + 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 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'), + JSON.stringify({ dependencies: { resend: '^4.0.0' } }), + ); + const store = new SessionStore(buildSession({ installDir: tmpDir })); + await scanCodebaseFor(PROGRAM, store.readyContext()); + store.declineSharing(); + store.setCredentials(CREDENTIALS); + store.setApiUser(USER); + + 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 new file mode 100644 index 000000000..67a5efa39 --- /dev/null +++ b/src/services/codebase-scan/email-providers.ts @@ -0,0 +1,46 @@ +import type { CodebaseScan } from './scan'; + +const EMAIL_PROVIDER_KINDS: ReadonlySet = new Set([ + 'AwsSes', + 'Beehiiv', + 'BigMailer', + 'Brevo', + 'CampaignMonitor', + 'ConvertKit', + 'Drip', + '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; +}; + +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/codebase-scan/report.ts b/src/services/codebase-scan/report.ts index 16011f56f..f0ba8b3af 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; + if (session.apiUser) 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/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..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; @@ -755,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 13267ee12..ce4d6982a 100644 --- a/src/shared/utils/analytics.ts +++ b/src/shared/utils/analytics.ts @@ -300,6 +300,13 @@ export class Analytics { }); } + setPersonProperties(properties: Record) { + this.client.setPersonProperties({ + distinctId: this.distinctId ?? this.anonymousId, + properties, + }); + } + groupIdentify( groupType: string, groupKey: string, 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';