This documentation is available as Markdown for AI agents and LLMs. See the full Markdown index or append .md to any documentation URL.
PostHog recipes for EAS Workflows
Edit page
A guided path from marking your first deploy in PostHog to gated rollouts, automatic kill switches, and approval-gated releases with EAS Workflows.
EAS Workflows can run PostHog actions as steps in your CI pipeline. Each recipe below is a complete workflow you can copy as-is, and together they build up to a full progressive delivery pipeline:
- Product analytics: Mark a deploy, Annotate a release, and Announce a release once users adopt it
- Feature flags: Release a feature behind a flag, Roll out or roll back on your error rate, Take a flag to full rollout with human approval, and turn a feature off manually or automatically on an error spike
- Error tracking: Upload source maps for readable stack traces, and roll out an EAS Update channel or revert on failure
A compact list of more recipes covers narrower needs. For every input each function accepts, see the EAS Workflows syntax reference.
Get started
One command sets up everything these recipes need. Run it from your project directory:
- eas integrations:posthog:connectIt links a PostHog project to your Expo project and saves the credentials these functions read as EAS environment variables. The Using PostHog guide walks through the full setup.
When the command asks for a personal API key, create it with the "Source map upload" preset plus the feature_flag:read, feature_flag:write, query:read, and annotation:write scopes. That one key covers every recipe on this page. If you connected earlier with a narrower key, create a new one and set it as the POSTHOG_CLI_API_KEY environment variable with the Sensitive visibility.
Workflows that run on GitHub events, such as a push tomain, need your EAS project linked to a GitHub repository. See Get started with EAS Workflows.
Mark a deploy on your PostHog timeline
Send a PostHog event every time you publish an update. Each deploy then shows up in your PostHog data, so you can check whether a change in a metric lines up with a release.
name: Publish update and mark it in PostHog on: push: branches: ['main'] jobs: publish: type: update params: branch: main mark_deploy: needs: [publish] steps: - uses: eas/posthog_capture_event with: event: ota_update_published properties: branch: main runtime_version: ${{ fromJSON(needs.publish.outputs.updates_json || '[]')[0].runtimeVersion }} update_group_id: ${{ needs.publish.outputs.first_update_group_id }}
How it works
- The
updatejob publishes an EAS Update to themainbranch. eas/posthog_capture_eventruns after it and records the deploy. Both properties come from the update job's outputs: the runtime version says which builds can run the update, and the update group ID points at the exact update. With thefingerprintruntime version policy, the runtime version changes whenever your native runtime does, based on your project's fingerprint.- Because no
distinct_idis set, the event is anonymous and does not create a person profile.
Annotate a release
Create a PostHog annotation when you publish an update. Annotations appear as markers on every chart in the project, so you can see each release directly on the graphs it affects.
name: Annotate release in PostHog on: push: branches: ['main'] jobs: publish: type: update params: branch: main annotate: needs: [publish] steps: - uses: eas/posthog_annotation with: content: Published update for runtime ${{ fromJSON(needs.publish.outputs.updates_json || '[]')[0].runtimeVersion }} to main
How it works
- The
updatejob publishes the update. eas/posthog_annotationpins the annotation to the current time, and the runtime version in the content identifies the release.
Upload source maps for error tracking
Upload your source maps so error tracking can symbolicate crashes back to your original code. How they get uploaded depends on the pre-packaged job that produced the bundle. Both paths need the PostHog setup from the Using PostHog guide.
A build uploads source maps on its own. The posthog-react-native/expo config plugin uploads them during the native build, so the job needs nothing extra:
name: Build for production on: push: branches: ['main'] jobs: build: type: build params: platform: ios profile: production
An update ships JavaScript only, so you upload its source maps after publishing. The update job can't run an extra step, and the source maps have to be on disk in the job that uploads them. So publish with eas update for one platform in a custom job and add the upload step there:
name: Publish update with source maps on: push: branches: ['main'] jobs: publish_update: steps: - uses: eas/checkout - uses: eas/install_node_modules - run: npx eas-cli@latest update --branch main --platform ios --auto --non-interactive - uses: eas/posthog_upload_sourcemaps with: directory: dist
How it works
eas update --platform iospublishes the update and leaves the export, source maps included, in the dist directory. Export one native platform per job: a full export also includes a web bundle, which PostHog's Hermes upload rejects.eas/posthog_upload_sourcemapsuploads those maps to PostHog error tracking, so stack traces from the update symbolicate back to your original code. It uses the PostHog Metro config, which gives each bundle the chunk IDs that match it to its source maps. See error tracking for how symbolication works.
Release a feature behind a flag
Keep a feature's rollout state in a file in your repo and let the workflow apply it to PostHog. Shipping a feature or widening its rollout becomes a pull request that edits one file.
{ "flag": "new-checkout", "rollout_percentage": 10 }
name: Apply the feature rollout from the repo on: push: branches: ['main'] paths: ['.eas/feature-rollout.json'] jobs: publish: type: update params: branch: main read_rollout: outputs: flag: ${{ steps.rollout.outputs.flag }} percent: ${{ steps.rollout.outputs.percent }} steps: - uses: eas/checkout - id: rollout run: | set-output flag "$(node -p "require('./.eas/feature-rollout.json').flag")" set-output percent "$(node -p "require('./.eas/feature-rollout.json').rollout_percentage")" apply_rollout: needs: [publish, read_rollout] steps: - uses: eas/posthog_flag_rollout with: flag: ${{ needs.read_rollout.outputs.flag }} active: true rollout_percentage: ${{ needs.read_rollout.outputs.percent }}
How it works
- The
pathsfilter runs this workflow only when .eas/feature-rollout.json changes, so the file is where you define the rollout state. read_rolloutreads the file withset-outputand exposes the values through the job'soutputs.eas/posthog_flag_rolloutapplies the flag state after both dependencies finish, so the flag flips only once the code that reads it is live.
Roll out or roll back on your error rate
Release to a small percentage of users, watch the error rate, and let the workflow decide. When errors stay low, it widens the flag to everyone. When they don't, it turns the flag off. Either way, nobody has to watch a dashboard in between.
name: Canary rollout that rolls forward or back on: push: branches: ['main'] jobs: publish: type: update params: branch: main canary: needs: [publish] steps: - uses: eas/posthog_flag_rollout with: flag: new-checkout active: true rollout_percentage: 25 error_gate: needs: [canary] steps: - uses: eas/posthog_wait_for_metric with: query: SELECT count() FROM events WHERE event = '$exception' AND timestamp > now() - INTERVAL 30 MINUTE operator: lte threshold: 5 interval_seconds: 60 timeout_seconds: 1800 full_rollout: needs: [error_gate] steps: - uses: eas/posthog_flag_rollout with: flag: new-checkout rollout_percentage: 100 roll_back: after: [error_gate] if: ${{ failure() }} steps: - uses: eas/posthog_flag_rollout with: flag: new-checkout active: false
How it works
- The
updatejob ships the code, theneas/posthog_flag_rolloutexposes it to 25% of users. eas/posthog_wait_for_metricruns a HogQL query and waits until the exception count over the last 30 minutes is 5 or fewer. Once the gate clears,full_rolloutwidens the flag to 100%. If a bad rollout keeps the count elevated, the gate times out and fails, andfull_rolloutnever runs.- On failure,
roll_backturns the flag off. It usesafterwithif: ${{ failure() }}because aneedsdependency would not work here. When aneedsdependency fails, the job is skipped before itsifcondition is evaluated.failure()reflects the whole workflow run, so keep this workflow focused on the rollout and its gate.
Roll out an EAS Update channel, or revert on failure
The rollout above controls exposure with a PostHog feature flag. If you roll out with EAS Update channel percentages instead, the same gate drives eas channel:rollout: publish an update, send a fraction of users to it, watch the error rate, then widen the rollout to everyone or revert it. It touches more of EAS than any other recipe here: the update job, channel rollouts, the metric gate, and an outcome event.
name: Channel rollout that widens or reverts on: push: branches: ['main'] jobs: publish: type: update params: branch: rollout start_rollout: needs: [publish] steps: - run: npx eas-cli@latest channel:rollout production --action create --branch rollout --percent 10 --runtime-version 1.0.0 --non-interactive error_gate: needs: [start_rollout] steps: - id: gate uses: eas/posthog_wait_for_metric with: query: SELECT count() FROM events WHERE event = '$exception' AND timestamp > now() - INTERVAL 15 MINUTE operator: lt threshold: 10 interval_seconds: 60 timeout_seconds: 900 - uses: eas/posthog_capture_event with: event: channel_rollout_cleared properties: error_count: ${{ steps.gate.outputs.value }} widen: needs: [error_gate] steps: - run: npx eas-cli@latest channel:rollout production --action end --outcome republish-and-revert --non-interactive revert: after: [error_gate] if: ${{ failure() }} steps: - run: npx eas-cli@latest channel:rollout production --action end --outcome revert --non-interactive
How it works
- The
updatejob publishes to therolloutbranch, theneas channel:rolloutstarts a rollout on theproductionchannel that sends 10% of users to the new update. The--runtime-versionvalue must match the published update's runtime version. eas/posthog_wait_for_metricholds until the exception count over the last 15 minutes stays under 10, then records the count that cleared the gate through the step'svalueoutput.- When the gate clears,
widenends the rollout with--outcome republish-and-revert, which publishes the new update to everyone. - When the gate fails,
revertends the rollout with--outcome revert, which sends users back to the previous update. It uses the sameafterplusif: ${{ failure() }}pattern as the flag rollback.
Take a flag to full rollout with human approval
Pause the workflow for an explicit approval in the EAS dashboard before a flag goes to 100%. Use this for rollouts you don't want to automate end to end.
name: Take a feature to GA with human approval jobs: approve: name: Approve new-checkout GA? type: require-approval go_full: needs: [approve] steps: - uses: eas/posthog_flag_rollout with: flag: new-checkout active: true rollout_percentage: 100 bookkeeping: needs: [go_full] steps: - uses: eas/posthog_capture_event with: event: feature_went_ga properties: flag: new-checkout
How it works
- The
require-approvaljob pauses the workflow until a person approves or rejects it in the EAS dashboard. - If approved,
eas/posthog_flag_rolloutrolls the flag out to 100%. If rejected, the job fails andgo_fullis skipped. - The follow-up event records when the flag reached full rollout.
Turn a feature off with a kill switch
Turn a feature off for everyone with a single command. This workflow has no on trigger, so it runs only when you start it, such as during an incident.
name: Disable new-checkout now jobs: disable_flag: steps: - uses: eas/posthog_flag_rollout with: flag: new-checkout active: false audit_trail: needs: [disable_flag] steps: - uses: eas/posthog_capture_event with: event: kill_switch_pulled properties: flag: new-checkout
How it works
-
Run it with:
Terminal-eas workflow:run kill-switch.yml -
eas/posthog_flag_rolloutturns the flag off, and the follow-up event records that it happened.
Turn a feature off automatically on an error spike
This is the kill switch from the previous recipe wired to an error gate, so it turns the feature off on its own when the error rate spikes after a deploy.
name: Turn off new-checkout automatically on an error spike on: push: branches: ['main'] jobs: publish: type: update params: branch: main error_gate: needs: [publish] steps: - uses: eas/posthog_wait_for_metric with: query: SELECT count() FROM events WHERE event = '$exception' AND timestamp > now() - INTERVAL 10 MINUTE operator: lt threshold: 20 interval_seconds: 60 timeout_seconds: 600 auto_kill_switch: after: [error_gate] if: ${{ failure() }} steps: - uses: eas/posthog_flag_rollout with: flag: new-checkout active: false - uses: eas/posthog_capture_event with: event: auto_kill_switch_pulled properties: flag: new-checkout
How it works
- The
updatejob publishes the update, andeas/posthog_wait_for_metricwaits until the exception count over the last 10 minutes is under 20. If a bad update keeps the count elevated, the gate times out and fails. - The
auto_kill_switchjob uses the sameafterplusif: ${{ failure() }}pattern as the flag rollback. eas/posthog_flag_rolloutturns the flag off, the same action as the manual kill switch, and records that it fired automatically.
Announce a release once users adopt it
Post a Slack message once enough users are on the new update. The workflow waits for real adoption instead of announcing as soon as the deploy finishes.
name: Announce release once adopted on: push: branches: ['main'] jobs: publish: type: update params: branch: main adoption_gate: needs: [publish] steps: - uses: eas/posthog_wait_for_metric with: query: SELECT count(DISTINCT distinct_id) FROM events WHERE timestamp > now() - INTERVAL 1 HOUR operator: gte threshold: 50 interval_seconds: 120 timeout_seconds: 3600 announce: needs: [adoption_gate] type: slack environment: production params: webhook_url: ${{ env.SLACK_WEBHOOK_URL }} message: The latest update is live and 50 or more users are on it.
How it works
- The
updatejob publishes the update. eas/posthog_wait_for_metricwaits until at least 50 distinct users have sent events in the last hour.- The
slackjob announces the release once the gate clears. SetSLACK_WEBHOOK_URLas an environment variable on EAS.
More recipes
Shorter recipes for narrower needs.
Wait for a specific event
Hold the workflow until a specific event arrives, such as a post-deploy smoke test reporting success, instead of gating on an aggregate metric.
name: Wait for smoke test to pass jobs: wait_for_smoke_test: steps: - uses: eas/posthog_wait_for_query with: query: SELECT count() > 0 FROM events WHERE event = 'smoke_test_passed' AND timestamp > now() - INTERVAL 30 MINUTE interval_seconds: 15 timeout_seconds: 600
eas/posthog_wait_for_query clears once the query returns true. The 30-minute window keeps an old smoke-test event from clearing the gate immediately.
Check your nightly error budget
Run a gate on a schedule instead of after a deploy, as a standalone health check.
name: Nightly error-budget check on: schedule: - cron: '0 6 * * *' jobs: assert_error_budget: steps: - uses: eas/posthog_wait_for_metric with: query: SELECT count() FROM events WHERE event = '$exception' AND timestamp > now() - INTERVAL 24 HOUR operator: lt threshold: 100 interval_seconds: 30 timeout_seconds: 60
The on.schedule.cron trigger runs this independently of any deploy, so it works as a standing health check rather than a gate on a specific rollout.
Mark a store submission
eas/posthog_capture_event works the same way for native releases as it does for updates.
name: Build, submit to the store, mark it in PostHog jobs: build: type: build params: platform: ios profile: production submit: needs: [build] type: submit params: build_id: ${{ needs.build.outputs.build_id }} profile: production mark_release: needs: [submit] steps: - uses: eas/posthog_capture_event with: event: store_build_submitted properties: platform: ios profile: production
Important notes
- Credentials come from the connect command. Every function defaults to the environment variables that
eas integrations:posthog:connectsets, and each accepts anapi_keyinput when you need to override them. To set them yourself, add these as EAS environment variables:EXPO_PUBLIC_POSTHOG_API_KEYforeas/posthog_capture_event, andPOSTHOG_CLI_API_KEYplusPOSTHOG_CLI_PROJECT_IDfor every other function. The syntax reference lists the scope each function requires. - A gate that times out fails the step.
eas/posthog_wait_for_metricandeas/posthog_wait_for_queryhave noignore_errorinput, so a gate that never passes stops the rollout instead of letting it continue. - Gates pass as soon as the condition holds. A gate checking for a low error count can pass on its first check, including right after a deploy, before users are on the new update. Pair it with an adoption gate, like the one in Announce a release once users adopt it, when you need errors to have had a chance to surface first.
- Scope queries to the current run. Event and metric queries return historical data. Add a
timestamp > now() - INTERVAL ...filter, or a property unique to this run, so an old event does not clear a gate immediately. Setting up release tagging attachesexpo_update_id,expo_channel, andexpo_runtime_versionto every client event, so you can filter a gate onproperties.expo_update_idto scope it to the exact release.