expo / sentry-expo

MIT License
202 stars 83 forks source link

Auto upload source maps for EAS Update #335

Closed krystofwoldrich closed 7 months ago

krystofwoldrich commented 1 year ago

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 commented 1 year ago

This would be amazing +1

fuelkoy commented 1 year ago

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 commented 1 year ago

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

mikevercoelen commented 1 year ago

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

jer-sen commented 1 year ago

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[bot] commented 11 months ago

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.

LordParsley commented 11 months ago

+1

wen-kai commented 10 months ago

+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 commented 9 months ago

@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 commented 8 months ago

Doesn't work for me either!

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

jer-sen commented 8 months ago

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 commented 7 months ago

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