Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto upload source maps for EAS Update #335

Closed
krystofwoldrich opened this issue May 16, 2023 · 12 comments
Closed

Auto upload source maps for EAS Update #335

krystofwoldrich opened this issue May 16, 2023 · 12 comments

Comments

@krystofwoldrich
Copy link
Contributor

At the moment sentry-expo uploads source maps to Sentry only during native app builds.

sentry-expo could ship with a script similar to the user solutions or if possible hook at the end of eas update.

User-created solutions:

@mikevercoelen
Copy link

This would be amazing +1

@fuelkoy
Copy link

fuelkoy commented May 23, 2023

Similar TS script based on @karbone4's comment made specifically for windows and working with expo-router (metro instead of webpack). Used with app.config.ts and includes--org and --project flags from extra prop

import { exec } from "child_process";
import util from "util";

// eslint-disable-next-line import/no-extraneous-dependencies
import { Command } from "commander";

import app from "./app.config";
import eas from "./eas.json";

// https://github.com/expo/sentry-expo/issues/319#issuecomment-1434954552
const promisifiedExec = util.promisify(exec);

const uploadAndroidSourceMap = async (updates: any) => {
  const appVersion = app().version;

  const androidVersionCode = app().android?.versionCode;
  const androidPackageName = app().android?.package;
  const androidUpdateId = updates.find(
    (update: any) => update.platform === "android",
  ).id;
  await promisifiedExec(
    `cd ./dist/bundles/ && move android*.js index.android.bundle`,
  );
  const release =
    await promisifiedExec(`cross-env ./node_modules/@sentry/cli/bin/sentry-cli \
        releases \
        --org ${app().extra.SENTRY_ORGANIZATION} \
        --project ${app().extra.SENTRY_PROJECT} \
        files ${androidPackageName}@${appVersion}+${androidVersionCode} \
        upload-sourcemaps \
        --dist ${androidUpdateId} \
        --rewrite \
        dist/bundles/index.android.bundle dist/bundles/android-*.map`);
  if (release.stderr) {
    console.error(release.stderr);
  } else {
    console.log(release.stdout);
  }
};

const uploadIosSourceMap = async (updates: any) => {
  const appVersion = app().version;

  const iosBuildNumber = app().ios?.buildNumber;
  const iosBundleID = app().ios?.bundleIdentifier;
  const iosUpdateId = updates.find(
    (update: any) => update.platform === "ios",
  ).id;
  await promisifiedExec(`cd ./dist/bundles/ && move ios*.js main.jsbundle`);
  const release =
    await promisifiedExec(`cross-env ./node_modules/@sentry/cli/bin/sentry-cli \
        releases \
        --org ${app().extra.SENTRY_ORGANIZATION} \
        --project ${app().extra.SENTRY_PROJECT} \
        files ${iosBundleID}@${appVersion}+${iosBuildNumber} \
        upload-sourcemaps \
        --dist ${iosUpdateId} \
        --rewrite \
        dist/bundles/main.jsbundle dist/bundles/ios-*.map`);
  if (release.stderr) {
    console.error(release.stderr);
  } else {
    console.log(release.stdout);
  }
};

const program = new Command()
  .requiredOption("-p, --profile  [value]", "EAS profile")
  .action(async (options) => {
    if (!Object.keys(eas.build).includes(options.profile)) {
      console.error("Profile must be includes in : ", Object.keys(eas.build));
    }
    const easBuild = eas.build[options.profile as keyof typeof eas.build];
    const { channel } = easBuild;

    const channelUpdates = await promisifiedExec(
      `eas update:list --branch ${channel} --non-interactive --json`,
    );
    if (channelUpdates.stderr) {
      console.error(channelUpdates.stderr);
    }

    // With update of web using now Metro, channelUpdates' creates 2 updates
    // Seemly web doesn't contain runtime and thus it is publishing
    // multiple update groups with the following message:
    // 👉 Since multiple runtime versions are defined, multiple update groups have been published.
    // For this reason the first element with 'android, ios' platforms will be found and the group gotten from it
    // TODO: As I understand sentry-expo doesn't work similarly for web with expo-router as Metro is used.
    const groupID = JSON.parse(channelUpdates.stdout).currentPage.find(
      (currentPage: any) => currentPage.platforms === "android, ios",
    ).group;
    const updates = await promisifiedExec(`eas update:view ${groupID} --json`);
    if (updates.stderr) {
      console.error(updates.stderr);
    }

    await uploadAndroidSourceMap(JSON.parse(updates.stdout));
    await uploadIosSourceMap(JSON.parse(updates.stdout));
  });

program.parse(process.argv);

Run with: ts-node --esm sentryRelease.ts -p production (since I needed to figure this out I will put it here to help others)

@karlvd
Copy link

karlvd commented Jun 13, 2023

+1
This worked with expo publish but no longer with EAS update

@mikevercoelen
Copy link

This is no longer working properly when using hermes (which is becoming a standard).

@jer-sen
Copy link

jer-sen commented Aug 9, 2023

If it can help someone, here is a config and a script that work for me:

Sentry.init({
 ...otherConfig,
  // Release and dist must match those used to upload sourcemaps after EAS builds and updates
  ...(Platform.OS === 'web' || __DEV__ || Updates?.isEmbeddedLaunch === true
    ? {} // With embedded bundle, default dist (Application.nativeBuildVersion) is ok
    : { dist: Updates.updateId }),
});
import { spawnSync } from 'child_process';
import readline from 'node:readline/promises'; // eslint-disable-line import/no-unresolved
import { readFileSync, copyFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
import { exec } from 'node:child_process';
import util from 'node:util';
import { z } from 'zod';
import { join } from 'node:path';
import axios from 'axios';
import { normalize } from 'path';

const APP_PATH = normalize('apps/myapp');
const APP_BUNDLES_SUBPATH = normalize('dist/bundles');

const skipEas = process.argv.includes('--skip-eas');

const promisifiedExec = util.promisify(exec);

const uploadSourceMap = ({
  bundleFileRegex,
  sentryBundleFileName,
  release,
  dist,
}: { bundleFileRegex: RegExp; sentryBundleFileName: string; release: string; dist: string }) => {
  const bundleFiles = readdirSync(join(APP_PATH, APP_BUNDLES_SUBPATH)).filter(
    (file) => bundleFileRegex.exec(file) !== null,
  );
  if (bundleFiles.length > 1) throw new Error('Multiple bundle files found');
  const bundleFile = bundleFiles[0];
  if (bundleFile === undefined) throw new Error('No bundle file found');

  const sourcemapFileName = bundleFile.replace(/.js$/u, '.map');

  copyFileSync(
    join(APP_PATH, APP_BUNDLES_SUBPATH, bundleFile),
    join(APP_PATH, APP_BUNDLES_SUBPATH, sentryBundleFileName),
  );

  try {
    // Remove absolute path prefix
    const sourceMap = z
      .object({ sources: z.array(z.string()) })
      .passthrough()
      .parse(
        JSON.parse(readFileSync(join(APP_PATH, APP_BUNDLES_SUBPATH, sourcemapFileName), 'utf8')),
      );
    let longestCommonPrefix = sourceMap.sources[0];
    for (const filePath of sourceMap.sources) {
      if (filePath.startsWith(longestCommonPrefix)) continue;
      while (!filePath.startsWith(longestCommonPrefix)) {
        longestCommonPrefix = longestCommonPrefix.slice(0, -1);
      }
    }
    for (let i = 0; i < sourceMap.sources.length; i++) {
      sourceMap.sources[i] = sourceMap.sources[i]
        .slice(longestCommonPrefix.length)
        .replaceAll('\\', '/');
    }
    writeFileSync(
      join(APP_PATH, APP_BUNDLES_SUBPATH, sourcemapFileName),
      JSON.stringify(sourceMap),
      'utf8',
    );

    const res = spawnSync(
      [
        'yarn sentry-cli releases --org stairwage --project sentry_project',
        `files ${release} upload-sourcemaps --no-dedupe --dist ${dist}`,
        join(APP_BUNDLES_SUBPATH, sentryBundleFileName),
        join(APP_BUNDLES_SUBPATH, sourcemapFileName),
      ].join(' '),
      {
        shell: true,
        stdio: 'inherit',
        cwd: APP_PATH,
      },
    );
    if (res.status !== 0) throw new Error('Sentry upload failed');
  } finally {
    unlinkSync(join(APP_PATH, APP_BUNDLES_SUBPATH, sentryBundleFileName));
  }
};

const askFor = async (what: string) => {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  const res = await rl.question(what + '\n');

  rl.close();
  return res;
};

const main = async () => {
  const bundleRes = skipEas
    ? 'n'
    : await askFor('Bundle and publish (or publish already bundlefiles in /dist)? y/n');
  if (!['y', 'n'].includes(bundleRes)) throw new Error('Invalid choice');
  const bundle = bundleRes === 'y';

  const appVersion = '0.1.2';
  const appVersionComment = 'New feature XXX';

  const runtimeVersion = '12';
  const buildNumber = '27';

  const nativeAppVersion = '0.1.0';

  const requiredNativeAppVersionStart = appVersion.split('.').slice(0, 2).join('.') + '.';
  if (
    !nativeAppVersion.startsWith(requiredNativeAppVersionStart) ||
    parseInt(nativeAppVersion.split('.')[2], 10) >= parseInt(appVersion.split('.')[2], 10)
  ) {
    throw new Error('Invalid native native release version');
  }

  // To compute bundle id for Sentry release and choose EAS update branche
  const stage = await askFor('Stage (dev, staging, prod):');
  if (!['prod', 'staging', 'dev'].includes(stage)) {
    throw new Error('Invalid stage');
  }

  const platform = await askFor('Platform (ios, android, all):');
  if (!['ios', 'android', 'all'].includes(platform)) {
    throw new Error('Invalid platform');
  }

  if (skipEas) {
    console.log('Skipping EAS update');
  } else {
    if (bundle) {
      console.log('Bundling and publishing EAS update to ' + stage);
      const res = spawnSync(
        `eas update -p ${platform} --branch ${stage} --message "${appVersionComment}"`,
        {
          shell: true,
          stdio: 'inherit',
          cwd: APP_PATH,
          env: {
            ...process.env,
            APP_VARIANT: stage,
          },
        },
      );
      if (res.status !== 0) {
        throw new Error('EAS update failed');
      }
    } else {
      console.log('Publishing already bundled EAS update to ' + stage);
      const res = spawnSync(
        `eas update -p ${platform} --branch ${stage} --message "${appVersionComment}" --skip-bundler`,
        {
          shell: true,
          stdio: 'inherit',
          cwd: APP_PATH,
          env: {
            ...process.env,
            APP_VARIANT: stage,
          },
        },
      );
      if (res.status !== 0) {
        throw new Error('EAS update failed');
      }
    }
  }

  const branchLastUpdateGroupsRes = await promisifiedExec(
    `eas update:list --branch ${stage} --json --non-interactive`,
    {
      cwd: APP_PATH,
      env: {
        ...process.env,
        APP_VARIANT: stage,
      },
    },
  );
  if (branchLastUpdateGroupsRes.stderr !== '') {
    console.error(`EAS updates listing error: ${branchLastUpdateGroupsRes.stderr}`);
  }

  let lastUpdateGroup = undefined;
  try {
    const branchLastUpdateGroups = z
      .object({
        name: z.string(),
        id: z.string(),
        currentPage: z.array(
          z.object({
            branch: z.string(),
            message: z.string(),
            runtimeVersion: z.string(),
            isRollBackToEmbedded: z.boolean(),
            group: z.string(),
            platforms: z.string(),
          }),
        ),
      })
      .parse(
        JSON.parse(
          /^(?:\n|[^{])*(\{(?:.|\n)*\})(?:\n|[^}])*$/u.exec(
            branchLastUpdateGroupsRes.stdout,
          )?.[1] ?? '',
        ),
      );
    if (branchLastUpdateGroups.name !== stage) throw new Error('Invalid branch name');
    lastUpdateGroup = branchLastUpdateGroups.currentPage[0];
    if (lastUpdateGroup === undefined) throw new Error('No update found');
    if (lastUpdateGroup.branch !== stage) throw new Error('Invalid branch name');
    if (!lastUpdateGroup.message.startsWith(`"${appVersionComment}"`)) {
      throw new Error('Invalid update message');
    }
    if (lastUpdateGroup.runtimeVersion !== runtimeVersion) {
      throw new Error('Invalid runtime version');
    }
    if (lastUpdateGroup.isRollBackToEmbedded) throw new Error('Invalid update type (roll back)');
    if (lastUpdateGroup.platforms !== (platform === 'all' ? 'android, ios' : platform)) {
      throw new Error('Invalid update platforms');
    }
  } catch (err) {
    console.log(branchLastUpdateGroupsRes.stdout);
    throw err;
  }

  const updates = await promisifiedExec(`eas update:view ${lastUpdateGroup.group} --json`);
  if (updates.stderr.length > 0) console.error(updates.stderr);

  const groupUpdatesRes = await promisifiedExec(`eas update:view ${lastUpdateGroup.group} --json`, {
    cwd: APP_PATH,
    env: {
      ...process.env,
      APP_VARIANT: stage,
    },
  });
  if (groupUpdatesRes.stderr !== '') {
    console.error(`EAS group view error: ${groupUpdatesRes.stderr}`);
  }

  let groupUpdates = undefined;
  try {
    groupUpdates = z
      .array(
        z.object({
          id: z.string(),
          createdAt: z.string(),
          group: z.string(),
          branch: z.string(),
          message: z.string(),
          runtimeVersion: z.string(),
          platform: z.enum(['android', 'ios']),
          manifestPermalink: z.string(),
          isRollBackToEmbedded: z.boolean(),
          gitCommitHash: z.string(),
        }),
      )
      .parse(
        JSON.parse(
          /^(?:\n|[^[])*(\[(?:.|\n)*\])(?:\n|[^\]])*$/u.exec(groupUpdatesRes.stdout)?.[1] ?? '',
        ),
      );
    for (const update of groupUpdates) {
      if (!skipEas && new Date(update.createdAt).valueOf() < Date.now() - 5 * 60 * 1000) {
        throw new Error('Update too old');
      }
      if (update.group !== lastUpdateGroup.group) throw new Error('Invalid group');
      if (update.branch !== stage) throw new Error('Invalid branch name');
      if (update.message.startsWith(`"${appVersionComment}"`)) {
        throw new Error('Invalid update message');
      }
      if (update.runtimeVersion !== runtimeVersion) throw new Error('Invalid runtime version');
      if (update.isRollBackToEmbedded) throw new Error('Invalid update type (roll back)');
    }
  } catch (err) {
    console.log(groupUpdatesRes.stdout);
    throw err;
  }

  // Upload to Sentry
  if (platform === 'android' || platform === 'all') {
    const update = groupUpdates.find((u) => u.platform === 'android');
    if (update === undefined) throw new Error('No android update found');

    const manifest = (await axios.get<string>(update.manifestPermalink)).data;
    const launchAssetKey = /"launchAsset":\{"hash":"[^"]+","key":"([^"]+)"/u.exec(manifest)?.[1];
    if (launchAssetKey === undefined) {
      console.log(manifest);
      throw new Error('Wrong manifest format');
    }

    console.log('Uploading android source map to Sentry');
    uploadSourceMap({
      bundleFileRegex: /^android-.*\.js$/u,
      sentryBundleFileName: 'index.android.bundle',
      release: `${`com.myorg.myapp${
        stage === 'prod' ? '' : `_${stage}`
      }`}@${nativeAppVersion}+${buildNumber}`,
      dist: update.id,
    });
  }
  if (platform === 'ios' || platform === 'all') {
    const update = groupUpdates.find((u) => u.platform === 'ios');
    if (update === undefined) throw new Error('No ios update found');

    const manifest = (await axios.get<string>(update.manifestPermalink)).data;
    const launchAssetKey = /"launchAsset":\{"hash":"[^"]+","key":"([^"]+)"/u.exec(manifest)?.[1];
    if (launchAssetKey === undefined) {
      console.log(manifest);
      throw new Error('Wrong manifest format');
    }

    console.log('Uploading ios source map to Sentry');
    uploadSourceMap({
      bundleFileRegex: /^ios-.*\.js$/u,
      sentryBundleFileName: launchAssetKey + '.bundle',
      release: `${`com.myorg.myapp${
        stage === 'prod' ? '' : `-${stage}`
      }`}@${nativeAppVersion}+${buildNumber}`,
      dist: update.id,
    });
  }
};

void main();

@github-actions
Copy link

github-actions bot commented Oct 8, 2023

This issue is stale because it has been open for 60 days with no activity. If there is no activity in the next 7 days, the issue will be closed.

@github-actions github-actions bot added the stale label Oct 8, 2023
@LordParsley
Copy link

+1

@github-actions github-actions bot removed the stale label Oct 8, 2023
@wen-kai
Copy link

wen-kai commented Oct 24, 2023

+1 after migrating to eas update and sdk49 (bare) we've lost our stacktraces in sentry. having some reliable resources to simplify this would be incredibly appreciated!

EDIT: this script saved us -- https://gist.github.com/nandorojo/8371475fe9912cb6b8d4f326664f1fc6

@mikevercoelen
Copy link

@wen-kai This script did not work for us, the upload succeeds, but still scrambled source maps, we're using Expo v49.

Is this script still functional for you guys?

@JuanRdBO
Copy link

JuanRdBO commented Dec 17, 2023

Doesn't work for me either!

I'd love an auto-upload script as well. It's sorely lacking

@jer-sen
Copy link

jer-sen commented Dec 25, 2023

FI I had to change Android bundle file name to "index.android.bundle" to fix my script above (I've edited the script's comment).

@krystofwoldrich
Copy link
Contributor Author

Hello everyone,
@sentry/react-native now supports Expo out of the box!

You can upload source maps for EAS Update as easily as npx sentry-expo-upload-sourcemaps dist.

Update to https://github.com/getsentry/sentry-react-native/releases/tag/5.16.0 or newer to get all the new features.

Migration guides available:

More config here:

SENTRY_PROJECT=project-slug \
SENTRY_ORG=org-slug \
SENTRY_AUTH_TOKEN=super-secret-token \
npx sentry-expo-upload-sourcemaps dist

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

No branches or pull requests

9 participants