Skip to content
Closed
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ This is the log of notable changes to EAS CLI and related packages.

### 🎉 New features

- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add a concurrency limit message experiment to `eas build`. ([#4611](https://github.com/expo/eas-cli/pull/4611) by [@williamgrosset](https://github.com/williamgrosset))

### 🐛 Bug fixes

### 🧹 Chores
Expand Down
1 change: 1 addition & 0 deletions packages/eas-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@expo/eas-build-job": "24.13.0",
"@expo/eas-json": "24.13.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "24.13.0",
"@expo/multipart-body-parser": "2.0.0",
Expand Down
26 changes: 26 additions & 0 deletions packages/eas-cli/src/__tests__/commands/utils-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import EasCommand from '../../commandUtils/EasCommand';
import { mockCommandContext } from './utils';

jest.mock('fs');

class TestCommand extends EasCommand {
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Experimentation,
};

async runAsync(): Promise<void> {}
}

describe(mockCommandContext, () => {
it('provides a disabled experimentation client that returns defaults', () => {
const ctx = mockCommandContext(TestCommand, {});
expect(ctx.experimentation.getUserNamespace('any').getParam('variant', 'control')).toBe(
'control'
);
expect(
ctx.experimentation.getAccountNamespace('any', { accountId: 'a' }).getParam('enabled', true)
).toBe(true);
expect(ctx.experimentation.getDeviceNamespace('any').getParam('count', 3)).toBe(3);
});
});
5 changes: 5 additions & 0 deletions packages/eas-cli/src/__tests__/commands/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { v4 as uuidv4 } from 'uuid';

import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
import { ExperimentationClient } from '../../experimentation/ExperimentationClient';
import {
AppFragment,
Role,
Expand Down Expand Up @@ -97,6 +99,9 @@ export function mockCommandContext<
if (contextKey === 'sessionManager') {
result.sessionManager = {};
}
if (contextKey === 'experimentation') {
result.experimentation = ExperimentationClient.createDisabled(ExperimentOverrides.empty());
}
if (contextKey === 'projectConfig') {
result.projectConfig = {
exp: overrides.exp ?? getMockExpoConfig(),
Expand Down
18 changes: 17 additions & 1 deletion packages/eas-cli/src/analytics/AnalyticsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,17 @@ export type AnalyticsEvent =
| SubmissionEvent
| MetadataEvent
| BillingEvent
| SimulatorEvent;
| SimulatorEvent
| ExperimentationEvent;

export enum CommandEvent {
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
}

export enum ExperimentationEvent {
EXPERIMENT_VIEWED = 'Experiment Viewed',
}

export enum BillingEvent {
SUBSCRIBE_COMMAND = 'billing cli subscribe command',
}
Expand Down Expand Up @@ -76,6 +81,8 @@ export enum BuildEvent {
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',

ANDROID_KEYSTORE_CREATE = 'build cli credentials keystore create',

CONCURRENCY_LIMIT_MESSAGE_SHOWN = 'build cli concurrency limit message shown',
}

export enum SimulatorEvent {
Expand Down Expand Up @@ -105,6 +112,8 @@ export interface Analytics {
export interface AnalyticsWithOrchestration extends Analytics {
setActor(actor: Actor): void;
flushAsync(): Promise<void>;
/** The persisted RudderStack anonymous ID, or null when analytics is disabled. */
getDeviceId(): string | null;
}

const USER_SETTINGS_KEY_AMPLITUDE_ENABLED = 'amplitudeEnabled';
Expand Down Expand Up @@ -183,6 +192,9 @@ class NoOpAnalytics implements AnalyticsWithOrchestration {
logEvent(): void {}
setActor(): void {}
async flushAsync(): Promise<void> {}
getDeviceId(): null {
return null;
}
}

const RudderstackAnalyticsConfig =
Expand Down Expand Up @@ -234,6 +246,10 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
this.identifiedActor = actor;
}

public getDeviceId(): string {
return this.persistentDeviceId;
}

public logEvent(name: AnalyticsEvent, properties: AnalyticsEventProperties): void {
const userId = this.identifiedActor?.id;
const deviceId = this.persistentDeviceId;
Expand Down
52 changes: 52 additions & 0 deletions packages/eas-cli/src/analytics/__tests__/AnalyticsManager-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,55 @@ describe(isAnalyticsOptedOutAsync, () => {
await expect(isAnalyticsOptedOutAsync()).resolves.toBe(true);
});
});

describe('getDeviceId', () => {
// Backs UserSettings with an in-memory store so a value written by createAnalyticsAsync is read back.
function useInMemoryUserSettings(initial: Record<string, unknown>): void {
const stored: Record<string, unknown> = { ...initial };
(userSettingsMock.setAsync as jest.Mock).mockImplementation(
async (key: string, value: unknown) => {
stored[key] = value;
return {};
}
);
(userSettingsMock.getAsync as jest.Mock).mockImplementation(
async (key: string, defaultValue: unknown) => (key in stored ? stored[key] : defaultValue)
);
}

it('returns the persisted analytics device ID', async () => {
const analytics = await createAnalyticsAsync();
expect(analytics.getDeviceId()).toBe('persistent-device-id');
});

it('creates and persists a device ID when none is stored', async () => {
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) =>
key === 'analyticsDeviceId' ? null : defaultValue
);
const analytics = await createAnalyticsAsync();
const deviceId = analytics.getDeviceId();
expect(deviceId).toMatch(/^[0-9a-f-]{36}$/);
expect(userSettingsMock.setAsync).toHaveBeenCalledWith('analyticsDeviceId', deviceId);
});

it('is null when DISABLE_EAS_ANALYTICS is set', async () => {
useInMemoryUserSettings({ analyticsDeviceId: 'persistent-device-id' });
process.env.DISABLE_EAS_ANALYTICS = '1';
const analytics = await createAnalyticsAsync();
expect(analytics.getDeviceId()).toBeNull();
});

it('is null when running behind https_proxy', async () => {
process.env.https_proxy = 'http://proxy.local:8080';
const analytics = await createAnalyticsAsync();
expect(analytics.getDeviceId()).toBeNull();
});

it('is null when analytics were turned off with eas analytics off', async () => {
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) =>
key === 'analyticsEnabled' ? false : defaultValue
);
const analytics = await createAnalyticsAsync();
expect(analytics.getDeviceId()).toBeNull();
});
});
Loading
Loading