Skip to content

Instantly share code, notes, and snippets.

@nandorojo
Last active January 11, 2024 02:03
Show Gist options
  • Star 17 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nandorojo/8371475fe9912cb6b8d4f326664f1fc6 to your computer and use it in GitHub Desktop.
Save nandorojo/8371475fe9912cb6b8d4f326664f1fc6 to your computer and use it in GitHub Desktop.
EAS Update + Sentry Source Maps

EAS Update + Sentry Sourcemap upload

  1. Copy the TS file into your app. I put it in scripts/eas-update.ts
  2. Call the script with npx ts-node scripts/eas-update.ts <eas-script-here>
npx ts-node scripts/eas-update.ts npx eas-cli@latest update -p ios

You should provide the following env variables too:

  • EAS_UPDATE_MESSAGE
  • EAS_UPDATE_BRANCH
EAS_UPDATE_MESSAGE="works!" EAS_UPDATE_BRANCH=staging npx ts-node scripts/eas-update.ts npx eas-cli@latest update -p ios

All set. Be sure to call this script from the root of your Expo app.

For instance, you might add the following to your package.json's scripts:

{
  "scripts": {
    "eas-update": "npx ts-node scripts/eas-update.ts npx eas-cli@latest update -p ios"
  }
}

If you do that, just be sure to set EAS_UPDATE_MESSAGE and EAS_UPDATE_BRANCH when calling yarn eas-update.

Context

Meant to solve this issue: expo/sentry-expo#253

Tested with SDK 48 and it's working for me.

import { getConfig } from '@expo/config'
import fs from 'fs'
import spawnAsync from '@expo/spawn-async'
import chalk from 'chalk'
import path from 'path'
const appDir = process.cwd()
console.log()
console.log(chalk.green('Sentry source maps script. Working directory:'))
console.log(appDir)
console.log()
console.log(chalk.green('Importing Expo Config...'))
const config = getConfig(appDir, { skipSDKVersionRequirement: true })
if (!config) {
throw new Error(
'Failed to import Expo config. Are you in your app directory?'
)
}
console.log('Expo Config imported for', chalk.blue(config.exp.name))
const skipUpdate = 'SKIP_EAS_UPDATE' in process.env
const run = async () => {
// read in arguments from the CLI script
const [command, ..._args] = process.argv.slice(2)
const args = [
..._args,
'--message',
`${process.env.EAS_UPDATE_MESSAGE!}`,
'--branch',
`${process.env.EAS_UPDATE_BRANCH!}`,
]
try {
const updateProcess = spawnAsync(command, args, {
stdio: ['inherit', 'pipe', 'pipe'],
env: process.env,
cwd: process.cwd(),
})
const {
child: { stdout, stderr },
} = updateProcess
if (!(stdout && stderr)) {
throw new Error('Failed to spawn eas-cli')
}
let output: string[] = []
console.log()
console.log(
chalk.green('[eas-update-sentry] Running the follwing command:')
)
console.log(command, args.join(' '))
console.log()
stdout.on('data', (data: any) => {
const stringData: string = data.toString('utf8')
console.log(chalk.green('[eas-update-sentry]'), stringData)
output = stringData.split('\n').map((s) => s.trim())
})
await updateProcess
const findUpdateId = (output: string[], platform: 'android' | 'ios') => {
return output
.find((line) => line.toLowerCase().includes(`${platform} update id`))
?.split(' ')
.map((r) => r.trim())
.pop()
?.trim()
}
const iosUpdateId = findUpdateId(output, 'ios')
const androidUpdateId = findUpdateId(output, 'android')
const getBundles = () => {
const bundles = fs.readdirSync(path.resolve(appDir, 'dist/bundles'))
const iosBundle = bundles.find(
(s) => s.startsWith('ios-') && s.endsWith('.js')
)
const iosMap = bundles.find(
(s) => s.startsWith('ios-') && s.endsWith('.map')
)
const androidBundle = bundles.find(
(s) => s.startsWith('android-') && s.endsWith('.js')
)
const androidMap = bundles.find(
(s) => s.startsWith('android-') && s.endsWith('.map')
)
return { iosBundle, iosMap, androidBundle, androidMap }
}
const { iosBundle, iosMap, androidBundle, androidMap } = getBundles()
const uploadSourceMap = async ({
updateId,
buildNumber,
bundleIdentifier,
platform,
}: {
updateId: string
buildNumber: string | number
bundleIdentifier: string
platform: 'android' | 'ios'
}) => {
const sentryConfig = config.exp.hooks?.postPublish?.find((h) =>
h.file?.includes('upload-sourcemaps')
)?.config
const version = config.exp.version || config.exp.runtimeVersion
const result = spawnAsync(
'npx',
[
'@sentry/cli',
'releases',
'files',
`${bundleIdentifier}@${version}+${buildNumber}`,
'upload-sourcemaps',
'--dist',
updateId,
'--rewrite',
platform == 'ios'
? `dist/bundles/main.jsbundle`
: `dist/bundles/index.android.bundle`,
platform == 'ios'
? `dist/bundles/${iosMap}`
: `dist/bundles/${androidMap}`,
],
{
env: {
...process.env,
SENTRY_ORG: sentryConfig?.organization || process.env.SENTRY_ORG,
SENTRY_PROJECT: sentryConfig?.project || process.env.SENTRY_PROJECT,
SENTRY_AUTH_TOKEN:
sentryConfig?.authToken || process.env.SENTRY_AUTH_TOKEN,
SENTRY_URL:
sentryConfig?.url ||
process.env.SENTRY_URL ||
'https://sentry.io/',
},
}
)
result.child.stdout?.on('data', (data) => {
console.log(
chalk.green('[eas-update-sentry]'),
data
.toString('utf8')
.split('\n')
.map((l: string) => `[Upload ${platform} sourcemaps] ${l}`)
.join('\n')
)
})
await result
}
const uploadIosSourceMap = async () => {
if (iosUpdateId && iosBundle && iosMap) {
console.log()
console.log(
chalk.green('[eas-update-sentry] Updating iOS Bundle File...\n'),
console.log('[update-id]', iosUpdateId)
)
fs.renameSync(`dist/bundles/${iosBundle}`, 'dist/bundles/main.jsbundle')
const iOSConfig = {
bundleIdentifier: config.exp.ios?.bundleIdentifier,
buildNumber: config.exp.ios?.buildNumber,
}
if (Object.values(iOSConfig).every(Boolean)) {
await uploadSourceMap({
updateId: iosUpdateId,
buildNumber: iOSConfig.buildNumber!,
bundleIdentifier: iOSConfig.bundleIdentifier!,
platform: 'ios',
})
} else {
console.log(
chalk.yellow(
'[eas-update-sentry] Skipping iOS, missing the following values from your app.config file:',
Object.entries(iOSConfig)
.filter(([key, value]) => !value)
.map(([key]) => key)
.join(' ')
)
)
}
} else {
console.log(
chalk.yellow(
'[eas-update-sentry] Skipping iOS, missing the following values:',
Object.entries({ iosUpdateId, iosBundle, iosMap })
.filter(([key, value]) => !value)
.map(([key]) => key)
.join(' ')
)
)
}
}
const uploadAndroidSourceMap = async () => {
if (androidUpdateId && androidBundle && androidMap) {
console.log()
console.log(
chalk.green('[eas-update-sentry] Updating Android Bundle File...')
)
fs.renameSync(
`dist/bundles/${androidBundle}`,
'dist/bundles/index.android.bundle'
)
const androidConfig = {
package: config.exp.android?.package,
versionCode: config.exp.android?.versionCode,
}
if (Object.values(androidConfig).every(Boolean)) {
await uploadSourceMap({
updateId: androidUpdateId,
buildNumber: androidConfig.versionCode!,
bundleIdentifier: androidConfig.package!,
platform: 'android',
})
} else {
console.log(
chalk.yellow(
'[eas-update-sentry] Skipping Android, missing the following values from your app.config file:',
Object.entries(androidConfig)
.filter(([key, value]) => !value)
.map(([key]) => key)
.join(' ')
)
)
}
} else {
console.log(
chalk.yellow(
'[eas-update-sentry] Skipping Android, missing the following values:',
Object.entries({ androidUpdateId, androidBundle, androidMap })
.filter(([key, value]) => !value)
.map(([key]) => key)
.join(' ')
)
)
}
}
await Promise.all([uploadIosSourceMap(), uploadAndroidSourceMap()])
} catch (error: any) {
process.exit()
}
}
run().then((r) => {
console.log(chalk.yellow('Done!'))
})
@TomasSestak
Copy link

Do you fill in release in Sentry.init? Could you share your whole options object? I cant't get it working

@kirtikapadiya
Copy link

kirtikapadiya commented Apr 24, 2023

@nandorojo I try above solution but source maps was not properly upload.
image

@matheuscouto
Copy link

Amazing work! That did the job for me, but for some reason, file paths were including the entire system path, not just the relative project path, so I had to add this part in line 98:

    // Add this function to modify the source map file paths
    const modifySourceMapPaths = (sourceMapPath: string) => {
      const sourceMap = JSON.parse(fs.readFileSync(path.resolve(appDir, `dist/bundles/${sourceMapPath}`), "utf8"));

      sourceMap.sources = sourceMap.sources.map((filePath: string) => {
        return path.relative(appDir, filePath);
      });

      fs.writeFileSync(path.resolve(appDir, `dist/bundles/${sourceMapPath}`), JSON.stringify(sourceMap));
    };

    // Call the function for each source map
    if (iosMap) modifySourceMapPaths(iosMap);
    if (androidMap) modifySourceMapPaths(androidMap);

@nandorojo
Copy link
Author

FYI: if you use hermes, sentry may say the source map is 0 bytes.

@matheuscouto weird. are you on windows?

@matheuscouto
Copy link

@nandorojo
Copy link
Author

For Sentry.init, I just use the dsn.

I wish Expo had a real integration for this. It’s so annoying haha

@matheuscouto
Copy link

Definitely seems something that could be already built-in hahaha

@elevyg
Copy link

elevyg commented Jun 21, 2023

I edited some things because i use remote version and had to use javascript because ts-node wasn't compiling due to strict rules in my project.

Still seems like Sentry is not catching the dist on update

// ... same code ...

  const versionCommand = "npx";

  const versionArgs = ["eas-cli@latest", "build:version:get", "-p", "all"];

  const getRemoteVersions = async () => {
    try {
      const getRemoteVersionsCmd = spawnAsync(versionCommand, versionArgs, {
        stdio: ["inherit", "pipe", "pipe"],
        env: process.env,
        cwd: process.cwd(),
      });
      const {
        child: { stdout, stderr },
      } = getRemoteVersionsCmd;

      if (!(stdout && stderr)) {
        throw new Error("Failed to spawn eas-cli");
      }
      let output = [];
      console.log();
      console.log(
        chalk.green("[eas-update-sentry] Running the follwing command:"),
      );
      console.log(versionCommand, versionArgs.join(" "));
      console.log();

      stdout.on("data", (data) => {
        const stringData = data.toString("utf8");
        console.log(chalk.green("[eas-update-sentry]"), stringData);
        output = [
          ...output,
          stringData
            .split("\n")
            .map((s) => s.trim())
            .at(0),
        ];
      });
      await getRemoteVersionsCmd;

      const versions = output.reduce((acc, curr) => {
        const values = curr.split(" - ");
        if (values[0] === "Android versionCode") {
          acc.android = values[1];
        }
        if (values[0] === "iOS buildNumber") {
          acc.ios = values[1];
        }
        return acc;
      }, {});

      return versions;
    } catch (err) {
      throw new Error("Failed to fetch remote versions");
      process.exit();
    }
  };

  // ... same code .. 

    const buildNumber = await getRemoteVersions();

    await Promise.all([
      uploadIosSourceMap(buildNumber.ios),
      uploadAndroidSourceMap(buildNumber.android),
    ]);
  } catch (error) {
    process.exit();
  }
};

run().then((r) => {
  console.log(chalk.yellow("Done!"));
});

@nandorojo
Copy link
Author

nandorojo commented Aug 30, 2023

@elevyg have you confirmed that your script is right? a lot of the logic looks very different than mine so i can’t say that it looks right. i would log and confirm that you’re getting the correct values / files as expected and sending them to sentry.

for example, your output might only be adding the first line by using .at(0)? not sure.

@elevyg
Copy link

elevyg commented Aug 31, 2023

Hey @nandorojo, tbh is not working. Sentry is not recognizing the bundle.

But I just run the code and the output that has the .at(0) returned:
[OUTPUT] [ 'Android versionCode - 33', 'iOS buildNumber - 46' ].
So it's working as I expect that part of the code.

Seems like I have an error in the Sentry.init(). I'm waiting until I upgrade to Expo 49 to invest time on resolving this. Looks like Sentry has a major update that might solve the issue (?).

@SohelIslamImran
Copy link

I tried this... But sentry not showing the Stack Trace correctly.. What's wrong? Does this work for you @nandorojo ?
image

image

@nandorojo
Copy link
Author

I think SDK 49 had breaking changes for the output of the dist folder and we need to change it accordingly. Expo needs to fix this themselves ASAP, it’s really a terrible experience

@nandorojo
Copy link
Author

Looks like Expo has renamed the files in the dist/bundles directory. I am updating the code here to reflect this. Please try it once I do and let me know if it's fixed.

Has anyone fixed this themselves btw?

@SohelIslamImran
Copy link

SohelIslamImran commented Oct 23, 2023

Now even with eas build with no OTA updates, the stack trace not showing.🙁

image

@nandorojo
Copy link
Author

Maybe try tweeting for expo to add support for this. I’ve tried my best

@wen-kai
Copy link

wen-kai commented Oct 24, 2023

eas update w/ sdk 49 works great with this script, thank you so much!
i updated getBundles() to look for hermes:

const getBundles = () => {
            const bundles = fs.readdirSync(path.resolve(appDir, 'dist/bundles'))

            let iosBundle = bundles.find(
                (s) => s.startsWith('ios-') && s.endsWith('.js')
            )
            if (!iosBundle) {
                iosBundle = bundles.find(
                    (s) => s.startsWith('ios-') && s.endsWith('.hbc')
                )
            }
            const iosMap = bundles.find(
                (s) => s.startsWith('ios-') && s.endsWith('.map')
            )

            let androidBundle = bundles.find(
                (s) => s.startsWith('android-') && s.endsWith('.js')
            )
            if (!androidBundle) {
                androidBundle = bundles.find(
                    (s) => s.startsWith('android-') && s.endsWith('.hbc')
                )
            }
            const androidMap = bundles.find(
                (s) => s.startsWith('android-') && s.endsWith('.map')
            )

            return { iosBundle, iosMap, androidBundle, androidMap }
        }

@mikevercoelen
Copy link

We've been trying this script, but we're still getting no source maps properly in Sentry with eas updates.

We're using sentry-expo v7.1.1 and Expo v49

@nandorojo Is this script still functional?

@nandorojo
Copy link
Author

I think so, but you need to change the files to .hbc

I’m also facing issues where the CLI output isn’t read properly so it isn’t sending to sentry. I’m also using auto increment build numbers so it’s not uploading properly.

All-in-all, it’s super annoying that sentry hasn’t shipped this yet. Please see my open issue on their repo.

@nandorojo
Copy link
Author

And please comment there asking for it!

@peter-jozsa
Copy link

thank you for creating this little script for us, I managed to make it work with SDK 49. I made the following changes to achieve this:

  • add .hbc file support to getBundles() (like others mentioned it earlier)
    const getBundles = () => {
      const bundles = fs.readdirSync(path.resolve(appDir, 'dist/bundles'))

      const iosBundle = bundles.find(
        (s) => s.startsWith('ios-') && (s.endsWith('.js') || s.endsWith('.hbc')) // <- this line changed
      )
      const iosMap = bundles.find(
        (s) => s.startsWith('ios-') && s.endsWith('.map')
      )

      const androidBundle = bundles.find(
        (s) =>
          s.startsWith('android-') && (s.endsWith('.js') || s.endsWith('.hbc')) // <- this line changed
      )
      const androidMap = bundles.find(
        (s) => s.startsWith('android-') && s.endsWith('.map')
      )

      return { iosBundle, iosMap, androidBundle, androidMap }
    }
  • fix a bug in eas update command output collection that caused "update ID" lookup issues
    stdout.on('data', (data: any) => {
      const stringData: string = data.toString('utf8')
      console.log(chalk.green('[eas-update-sentry]'), stringData)
      output.push(...stringData.split('\n').map((s) => s.trim())) // <- this line changed
    })
modified eas-update.ts
import { getConfig } from '@expo/config'
import fs from 'fs'
import spawnAsync from '@expo/spawn-async'
import chalk from 'chalk'
import path from 'path'

const appDir = process.cwd()

console.log()
console.log(chalk.green('Sentry source maps script. Working directory:'))
console.log(appDir)

console.log()

console.log(chalk.green('Importing Expo Config...'))
const config = getConfig(appDir, { skipSDKVersionRequirement: true })
if (!config) {
  throw new Error(
    'Failed to import Expo config. Are you in your app directory?'
  )
}
console.log('Expo Config imported for', chalk.blue(config.exp.name))

const skipUpdate = 'SKIP_EAS_UPDATE' in process.env

const run = async () => {
  //   read in arguments from the CLI script
  const [command, ..._args] = process.argv.slice(2)

  const args = [
    ..._args,
    '--message',
    `${process.env.EAS_UPDATE_MESSAGE!}`,
    '--branch',
    `${process.env.EAS_UPDATE_BRANCH!}`,
  ]

  try {
    const updateProcess = spawnAsync(command, args, {
      stdio: ['inherit', 'pipe', 'pipe'],
      env: process.env,
      cwd: process.cwd(),
    })
    const {
      child: { stdout, stderr },
    } = updateProcess

    if (!(stdout && stderr)) {
      throw new Error('Failed to spawn eas-cli')
    }

    const output: string[] = []
    console.log()
    console.log(
      chalk.green('[eas-update-sentry] Running the follwing command:')
    )
    console.log(command, args.join(' '))
    console.log()
    stdout.on('data', (data: any) => {
      const stringData: string = data.toString('utf8')
      console.log(chalk.green('[eas-update-sentry]'), stringData)
      output.push(...stringData.split('\n').map((s) => s.trim()))
    })
    await updateProcess

    const findUpdateId = (output: string[], platform: 'android' | 'ios') => {
      return output
        .find((line) => line.toLowerCase().includes(`${platform} update id`))
        ?.split(' ')
        .map((r) => r.trim())
        .pop()
        ?.trim()
    }

    const iosUpdateId = findUpdateId(output, 'ios')
    const androidUpdateId = findUpdateId(output, 'android')

    const getBundles = () => {
      const bundles = fs.readdirSync(path.resolve(appDir, 'dist/bundles'))

      const iosBundle = bundles.find(
        (s) => s.startsWith('ios-') && (s.endsWith('.js') || s.endsWith('.hbc'))
      )
      const iosMap = bundles.find(
        (s) => s.startsWith('ios-') && s.endsWith('.map')
      )

      const androidBundle = bundles.find(
        (s) =>
          s.startsWith('android-') && (s.endsWith('.js') || s.endsWith('.hbc'))
      )
      const androidMap = bundles.find(
        (s) => s.startsWith('android-') && s.endsWith('.map')
      )

      return { iosBundle, iosMap, androidBundle, androidMap }
    }
    const { iosBundle, iosMap, androidBundle, androidMap } = getBundles()

    const uploadSourceMap = async ({
      updateId,
      buildNumber,
      bundleIdentifier,
      platform,
    }: {
      updateId: string

      buildNumber: string | number
      bundleIdentifier: string
      platform: 'android' | 'ios'
    }) => {
      const sentryConfig = config.exp.hooks?.postPublish?.find((h) =>
        h.file?.includes('upload-sourcemaps')
      )?.config

      const version = config.exp.version || config.exp.runtimeVersion

      const result = spawnAsync(
        'npx',
        [
          '@sentry/cli',
          'releases',
          'files',
          `${bundleIdentifier}@${version}+${buildNumber}`,
          'upload-sourcemaps',
          '--dist',
          updateId,
          '--rewrite',
          platform == 'ios'
            ? `dist/bundles/main.jsbundle`
            : `dist/bundles/index.android.bundle`,
          platform == 'ios'
            ? `dist/bundles/${iosMap}`
            : `dist/bundles/${androidMap}`,
        ],
        {
          env: {
            ...process.env,
            SENTRY_ORG: sentryConfig?.organization || process.env.SENTRY_ORG,
            SENTRY_PROJECT: sentryConfig?.project || process.env.SENTRY_PROJECT,
            SENTRY_AUTH_TOKEN:
              sentryConfig?.authToken || process.env.SENTRY_AUTH_TOKEN,
            SENTRY_URL:
              sentryConfig?.url ||
              process.env.SENTRY_URL ||
              'https://sentry.io/',
          },
        }
      )

      result.child.stdout?.on('data', (data) => {
        console.log(
          chalk.green('[eas-update-sentry]'),
          data
            .toString('utf8')
            .split('\n')
            .map((l: string) => `[Upload ${platform} sourcemaps] ${l}`)
            .join('\n')
        )
      })

      await result
    }

    const uploadIosSourceMap = async () => {
      if (iosUpdateId && iosBundle && iosMap) {
        console.log()
        console.log(
          chalk.green('[eas-update-sentry] Updating iOS Bundle File...\n'),
          console.log('[update-id]', iosUpdateId)
        )

        fs.renameSync(`dist/bundles/${iosBundle}`, 'dist/bundles/main.jsbundle')
        const iOSConfig = {
          bundleIdentifier: config.exp.ios?.bundleIdentifier,
          buildNumber: config.exp.ios?.buildNumber,
        }
        if (Object.values(iOSConfig).every(Boolean)) {
          await uploadSourceMap({
            updateId: iosUpdateId,
            buildNumber: iOSConfig.buildNumber!,
            bundleIdentifier: iOSConfig.bundleIdentifier!,
            platform: 'ios',
          })
        } else {
          console.log(
            chalk.yellow(
              '[eas-update-sentry] Skipping iOS, missing the following values from your app.config file:',
              Object.entries(iOSConfig)
                .filter(([key, value]) => !value)
                .map(([key]) => key)
                .join(' ')
            )
          )
        }
      } else {
        console.log(
          chalk.yellow(
            '[eas-update-sentry] Skipping iOS, missing the following values:',
            Object.entries({ iosUpdateId, iosBundle, iosMap })
              .filter(([key, value]) => !value)
              .map(([key]) => key)
              .join(' ')
          )
        )
      }
    }

    const uploadAndroidSourceMap = async () => {
      if (androidUpdateId && androidBundle && androidMap) {
        console.log()
        console.log(
          chalk.green('[eas-update-sentry] Updating Android Bundle File...')
        )

        fs.renameSync(
          `dist/bundles/${androidBundle}`,
          'dist/bundles/index.android.bundle'
        )
        const androidConfig = {
          package: config.exp.android?.package,
          versionCode: config.exp.android?.versionCode,
        }
        if (Object.values(androidConfig).every(Boolean)) {
          await uploadSourceMap({
            updateId: androidUpdateId,
            buildNumber: androidConfig.versionCode!,
            bundleIdentifier: androidConfig.package!,
            platform: 'android',
          })
        } else {
          console.log(
            chalk.yellow(
              '[eas-update-sentry] Skipping Android, missing the following values from your app.config file:',
              Object.entries(androidConfig)
                .filter(([key, value]) => !value)
                .map(([key]) => key)
                .join(' ')
            )
          )
        }
      } else {
        console.log(
          chalk.yellow(
            '[eas-update-sentry] Skipping Android, missing the following values:',
            Object.entries({ androidUpdateId, androidBundle, androidMap })
              .filter(([key, value]) => !value)
              .map(([key]) => key)
              .join(' ')
          )
        )
      }
    }

    await Promise.all([uploadIosSourceMap(), uploadAndroidSourceMap()])
  } catch (error: any) {
    process.exit()
  }
}

run().then((r) => {
  console.log(chalk.yellow('Done!'))
})

@mikevercoelen
Copy link

@peter-jozsa Thanks! How do you initialize Sentry? Are you using sentry-expo or are you using sentry/react-native directly? What are the dist and release params? Do you omit them?

@peter-jozsa
Copy link

Well we use sentry-expo and omit dist and release params. Now i've started reading about these params I feel like our solution is not complete yet...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment