Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/experimentation-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---

Add experimentation client.
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": "25.0.0",
"@expo/eas-json": "25.0.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "25.0.0",
"@expo/multipart-body-parser": "2.0.0",
Expand Down
25 changes: 25 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,25 @@
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', async () => {
const ctx = mockCommandContext(TestCommand, {});
const client = await ctx.experimentation.getClientAsync();
expect(client.getUserNamespace('any').getParam('variant', 'control')).toBe('control');
expect(client.getAccountNamespace('any', { accountId: 'a' }).getParam('enabled', true)).toBe(
true
);
expect(client.getDeviceNamespace('any').getParam('count', 3)).toBe(3);
});
});
8 changes: 8 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,9 @@ 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 { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
import {
AppFragment,
Role,
Expand Down Expand Up @@ -96,6 +99,11 @@ export function mockCommandContext<
if (contextKey === 'sessionManager') {
result.sessionManager = {};
}
if (contextKey === 'experimentation') {
result.experimentation = LazyExperimentationClient.fromClient(
ExperimentationClient.createDisabled(ExperimentOverrides.empty())
);
}
if (contextKey === 'projectConfig') {
result.projectConfig = {
exp: overrides.exp ?? getMockExpoConfig(),
Expand Down
16 changes: 15 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 @@ -105,6 +110,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 +190,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 +244,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();
});
});
9 changes: 9 additions & 0 deletions packages/eas-cli/src/commandUtils/EasCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import nullthrows from 'nullthrows';
import AnalyticsContextField from './context/AnalyticsContextField';
import ContextField from './context/ContextField';
import DynamicLoggedInContextField from './context/DynamicLoggedInContextField';
import ExperimentationContextField from './context/ExperimentationContextField';
import {
DynamicPrivateProjectConfigContextField,
DynamicPublicProjectConfigContextField,
Expand Down Expand Up @@ -148,6 +149,14 @@ export default abstract class EasCommand extends Command {
ProjectId: {
projectId: new ProjectIdContextField(),
},
/**
* Experimentation client for user, account, and device experiments.
* Declare a login field first so user experiments see the actor.
* Account experiments take the account ID from the command.
*/
Experimentation: {
experimentation: new ExperimentationContextField(),
},
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const analytics: AnalyticsWithOrchestration = {
logEvent: jest.fn((): void => {}),
setActor: jest.fn((): void => {}),
flushAsync: jest.fn(async (): Promise<void> => {}),
getDeviceId: jest.fn((): string | null => null),
};

beforeEach(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
import {
ExperimentationClient,
createLoggerConfig,
} from '../../experimentation/ExperimentationClient';
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
import { ExperimentationConfigQuery } from '../../graphql/queries/ExperimentationConfigQuery';
import Log from '../../log';

/** Caps how long the config fetch and the actor lookup can delay a command. */
const REQUEST_TIMEOUT_MS = 3000;

const CI_ENVIRONMENT_FLAGS = ['EAS_BUILD', 'CI'];

function isRunningInCi(): boolean {
return CI_ENVIRONMENT_FLAGS.some(flag => !!process.env[flag]);
}

/**
* Never prompts, throws, writes files, or reads the app config. A failed config fetch disables
* all scopes. CI runs skip the fetch and get defaults.
* The config fetch runs on the first `getClientAsync` call, so commands that never read a variant
* make no request.
*/
export default class ExperimentationContextField extends ContextField<LazyExperimentationClient> {
// One handle per command run, so repeated getContextAsync calls cannot log duplicate exposures.
private readonly clientByAnalytics = new WeakMap<
AnalyticsWithOrchestration,
LazyExperimentationClient
>();

async getValueAsync(options: ContextOptions): Promise<LazyExperimentationClient> {
let client = this.clientByAnalytics.get(options.analytics);
if (!client) {
client = new LazyExperimentationClient(() => this.buildClientAsync(options));
this.clientByAnalytics.set(options.analytics, client);
}
return client;
}

private async buildClientAsync({
sessionManager,
analytics,
}: ContextOptions): Promise<ExperimentationClient> {
const overrides = ExperimentOverrides.fromEnv();

if (isRunningInCi()) {
Log.debug('[experimentation] Running in CI. Experiments return defaults.');
return ExperimentationClient.createDisabled(overrides);
}

const deviceId = analytics.getDeviceId();
if (deviceId === null) {
Log.debug('[experimentation] Analytics is disabled. Experiments return defaults.');
return ExperimentationClient.createDisabled(overrides);
}

try {
const graphqlClient = createGraphqlClient(
{
accessToken: sessionManager.getAccessToken(),
sessionSecret: sessionManager.getSessionSecret(),
},
{ requestTimeoutMs: REQUEST_TIMEOUT_MS }
);

// The session manager skips the request once an actor is cached. Without a cached actor
// (for example, a stale session) it makes its own request, so it needs the same cap.
const [actorResult, configsResult] = await Promise.allSettled([
sessionManager.getUserAsync({ requestTimeoutMs: REQUEST_TIMEOUT_MS }),
ExperimentationConfigQuery.getConfigsAsync(graphqlClient),
]);

if (configsResult.status === 'rejected') {
Log.debug(
'[experimentation] Failed to fetch the experiment config. Experiments return defaults.',
errorMessage(configsResult.reason)
);
return ExperimentationClient.createDisabled(overrides);
}

const configs = configsResult.value;
return new ExperimentationClient(
{
user: configs.userConfig,
account: configs.accountConfig,
device: configs.deviceConfig,
},
{
user: actorResult.status === 'fulfilled' ? (actorResult.value?.id ?? null) : null,
device: deviceId,
},
overrides,
createLoggerConfig(analytics)
);
} catch (error) {
Log.debug(
'[experimentation] Failed to initialize experiments. Experiments return defaults.',
errorMessage(error)
);
return ExperimentationClient.createDisabled(overrides);
}
}
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
Loading
Loading