diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de1fd583a..a88e2c6450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Add `eas/posthog_wait_for_metric` workflow function to gate workflow runs on a PostHog metric. ([#3945](https://github.com/expo/eas-cli/pull/3945) by [@gwdp](https://github.com/gwdp)) - [build-tools] Add `eas/posthog_upload_sourcemaps` workflow function to upload source maps to PostHog. ([#3947](https://github.com/expo/eas-cli/pull/3947) by [@gwdp](https://github.com/gwdp)) - [build-tools] Add `eas/posthog_annotation` workflow function to create PostHog annotations from workflow runs. ([#3948](https://github.com/expo/eas-cli/pull/3948) by [@gwdp](https://github.com/gwdp)) +- [build-tools] Add `eas/posthog_wait_for_query` workflow function to gate workflow runs on a PostHog query. ([#3949](https://github.com/expo/eas-cli/pull/3949) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index f0c765f524..df6b73311a 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -53,6 +53,7 @@ import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken'; import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric'; import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps'; import { createPosthogAnnotationFunction } from './functions/createPosthogAnnotation'; +import { createWaitForPosthogQueryFunction } from './functions/waitForPosthogQuery'; import { CustomBuildContext } from '../customBuildContext'; export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { @@ -101,6 +102,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { createWaitForPosthogMetricFunction(), createUploadPosthogSourcemapsFunction(), createPosthogAnnotationFunction(), + createWaitForPosthogQueryFunction(), calculateEASUpdateRuntimeVersionFunction(), diff --git a/packages/build-tools/src/steps/functions/__tests__/waitForPosthogQuery.test.ts b/packages/build-tools/src/steps/functions/__tests__/waitForPosthogQuery.test.ts new file mode 100644 index 0000000000..891e396c5e --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/waitForPosthogQuery.test.ts @@ -0,0 +1,109 @@ +import fetch, { Response } from 'node-fetch'; + +import { createGlobalContextMock } from '../../../__tests__/utils/context'; +import { createWaitForPosthogQueryFunction } from '../waitForPosthogQuery'; + +jest.mock('@expo/logger'); +jest.mock('node-fetch'); + +function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response { + return { ok, status, json: async () => data } as unknown as Response; +} + +const ENV = { + POSTHOG_CLI_API_KEY: 'phx_test', + POSTHOG_CLI_PROJECT_ID: '123', + POSTHOG_CLI_HOST: 'https://us.posthog.com', +}; + +describe(createWaitForPosthogQueryFunction, () => { + const fetchMock = jest.mocked(fetch); + const waitForQuery = createWaitForPosthogQueryFunction(); + + afterEach(() => { + jest.resetAllMocks(); + }); + + function createStep( + callInputs: Record, + env: Record = ENV + ): ReturnType { + return waitForQuery.createBuildStepFromFunctionCall(createGlobalContextMock({}), { + callInputs, + env, + id: waitForQuery.id, + }); + } + + it('resolves when the query returns true', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[true]] })); + const step = createStep({ query: 'SELECT count() > 0 FROM events' }); + + await expect(step.executeAsync()).resolves.not.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init?.body as string).query).toEqual({ + kind: 'HogQLQuery', + query: 'SELECT count() > 0 FROM events', + }); + }); + + it('resolves when the query returns a nonzero number', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[1]] })); + const step = createStep({ query: 'q' }); + + await expect(step.executeAsync()).resolves.not.toThrow(); + }); + + it('keeps polling until the query turns true', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ results: [[0]] })) + .mockResolvedValueOnce(jsonResponse({ results: [[true]] })); + const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 5 }); + + await step.executeAsync(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('keeps polling after a transient failure and clears when the query recovers', async () => { + fetchMock + .mockRejectedValueOnce(new Error('ECONNRESET')) + .mockResolvedValueOnce(jsonResponse({ results: [[true]] })); + const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 5 }); + + await expect(step.executeAsync()).resolves.not.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('throws when the query never returns true within the timeout', async () => { + fetchMock.mockResolvedValue(jsonResponse({ results: [[false]] })); + const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 0.1 }); + + await expect(step.executeAsync()).rejects.toThrow(/did not return true within/); + }); + + it('throws when credentials are missing', async () => { + const step = createStep({ query: 'q' }, {}); + + await expect(step.executeAsync()).rejects.toThrow( + /Missing PostHog credentials: personal API key, project id/ + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws on a non-positive interval', async () => { + const step = createStep({ query: 'q', interval_seconds: 0 }); + + await expect(step.executeAsync()).rejects.toThrow(/greater than 0/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws on a 403 without retrying', async () => { + fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 })); + const step = createStep({ query: 'q' }); + + await expect(step.executeAsync()).rejects.toThrow(/forbidden \(403\)/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/build-tools/src/steps/functions/waitForPosthogQuery.ts b/packages/build-tools/src/steps/functions/waitForPosthogQuery.ts new file mode 100644 index 0000000000..f4e31e30b8 --- /dev/null +++ b/packages/build-tools/src/steps/functions/waitForPosthogQuery.ts @@ -0,0 +1,120 @@ +import { UserError } from '@expo/eas-build-job'; +import { bunyan } from '@expo/logger'; +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; + +import { PosthogClient, PosthogRetryableError } from '../utils/PosthogClient'; +import { PosthogUtils } from '../utils/PosthogUtils'; + +export function createWaitForPosthogQueryFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'posthog_wait_for_query', + name: 'Wait for a PostHog query', + __metricsId: 'eas/posthog_wait_for_query', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'query', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: true, + }), + BuildStepInput.createProvider({ + id: 'timeout_seconds', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: false, + defaultValue: PosthogUtils.DEFAULT_POLL_TIMEOUT_SECONDS, + }), + BuildStepInput.createProvider({ + id: 'interval_seconds', + allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, + required: false, + defaultValue: PosthogUtils.DEFAULT_POLL_INTERVAL_SECONDS, + }), + BuildStepInput.createProvider({ + id: 'api_key', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + BuildStepInput.createProvider({ + id: 'project_id', + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + required: false, + }), + ], + fn: async (stepCtx, { inputs, env, signal }) => { + const { logger } = stepCtx; + + const timeoutSeconds = inputs.timeout_seconds.value as number; + const intervalSeconds = inputs.interval_seconds.value as number; + PosthogUtils.assertPollBoundsPositive({ + timeoutSeconds, + intervalSeconds, + errorCode: 'EAS_POSTHOG_QUERY_INVALID_INTERVAL', + }); + + const result = PosthogClient.fromEnv({ + apiKeyOverride: inputs.api_key.value as string | undefined, + projectIdOverride: inputs.project_id.value as string | undefined, + env, + }); + if (!result.client) { + throw PosthogUtils.missingCredentialsError(result.missing); + } + const client = result.client; + + await waitForPosthogQueryAsync({ + logger, + client, + query: inputs.query.value as string, + timeoutSeconds, + intervalSeconds, + signal, + }); + }, + }); +} + +async function waitForPosthogQueryAsync({ + logger, + client, + query, + timeoutSeconds, + intervalSeconds, + signal, +}: { + logger: bunyan; + client: PosthogClient; + query: string; + timeoutSeconds: number; + intervalSeconds: number; + signal: AbortSignal | undefined; +}): Promise { + const deadline = Date.now() + timeoutSeconds * 1000; + + logger.info( + `Waiting for the PostHog query to return true. Checking every ${intervalSeconds}s for up to ${timeoutSeconds}s.` + ); + + for (;;) { + let cell: unknown; + try { + cell = await client.runQueryAsync(query, logger, signal); + } catch (error) { + if (!(error instanceof PosthogRetryableError)) { + throw error; + } + cell = undefined; + } + if (cell === true || (typeof cell === 'number' && cell !== 0)) { + logger.info('Query returned true.'); + return; + } + logger.info('Query is not true yet. Still waiting.'); + + if (!(await PosthogUtils.waitForNextPollAsync({ intervalSeconds, deadline, signal }))) { + throw new UserError( + 'EAS_POSTHOG_QUERY_TIMEOUT', + `The PostHog query did not return true within ${timeoutSeconds}s. It must select a single boolean, e.g. "SELECT count() > 100 FROM events".` + ); + } + } +}