Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion src/services/codebase-scan/__tests__/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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<string, string>,
): Promise<void> {
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;

Expand Down
46 changes: 46 additions & 0 deletions src/services/codebase-scan/email-providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { CodebaseScan } from './scan';

const EMAIL_PROVIDER_KINDS: ReadonlySet<string> = 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,
};
}
11 changes: 10 additions & 1 deletion src/services/codebase-scan/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'] } },
}),
Expand Down
16 changes: 16 additions & 0 deletions src/shared/utils/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
Expand Down
7 changes: 7 additions & 0 deletions src/shared/utils/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,13 @@ export class Analytics {
});
}

setPersonProperties(properties: Record<string, unknown>) {
this.client.setPersonProperties({
distinctId: this.distinctId ?? this.anonymousId,
properties,
});
}

groupIdentify(
groupType: string,
groupKey: string,
Expand Down
47 changes: 47 additions & 0 deletions src/tui/auth/__tests__/ci-region.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});
15 changes: 15 additions & 0 deletions src/tui/auth/project-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,18 @@ async function askForWizardLogin(options: {
return data;
}

async function readSignedUpUser(
accessToken: string,
appHost: string,
): Promise<ApiUser | null> {
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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';
Expand Down