onebox

Guide 26 of 30

For your agent: .md · all guides

On this page
  1. What it costs
  2. Step 1: what Apple gives you for free
  3. Step 2: Sentry in the app
  4. Step 3: source maps from local EAS builds
  5. Step 4: an error reporter for the API
  6. Keep personal data out of reports
  7. In-app bug reports for testers
  8. Product analytics (only if you need it)
  9. App Privacy answers and privacy policy lines
  10. Where the values go
  11. Check it works
  12. Common errors

Crash reports and error tracking

Runs on: your Mac (Xcode and the app), your browser (App Store Connect and Sentry), and your box (the API).

Most users do not report a crash. They close the app, and some delete it. This guide shows you what goes wrong after launch: first the free crash reports from Apple, then Sentry for JavaScript errors with readable stack traces, then an error reporter for your API. Set up the Apple part as soon as you have a TestFlight build. Add Sentry before the first public release.

What it costs

ToolFreeFirst paid step
Xcode Organizer crash reportsincluded in the Apple Developer Programnone
TestFlight feedbackincludednone
SentryDeveloper plan: 1 user, 5,000 errors a month, unlimited projects, 30-day history, email alertsTeam plan: 26 USD a month billed annually; unlimited users, 50,000 errors a month, up to 90-day history
PostHog (optional analytics)1 million events, 2,500 mobile session recordings and 100,000 exceptions a month; 1 project; no cardPay-as-you-go: the same free amount each month, then you pay for use above it

Checked 2026-09-28 at https://sentry.io/pricing/ and https://posthog.com/pricing. PostHog’s mobile replay amount checked 2026-10-09 at https://posthog.com/docs/session-replay/pricing. PostHog’s free plan stops at its limits, so it cannot charge you by surprise.

One Sentry account on the free plan covers the app and the API: make one project for each.

Step 1: what Apple gives you for free

Crashes in Xcode Organizer

In Xcode: Window, Organizer, then Crashes. Pick your app and a version.

The limit for a React Native app: Apple’s report shows the native side of a crash. When JavaScript throws and the app dies, it rarely tells you which line of your JavaScript failed. That is the reason for Sentry below.

TestFlight feedback

TestFlight testers can send a screenshot with a comment, or feedback about a crash, from the TestFlight app. Read it in App Store Connect: Apps, your app, TestFlight, then under Feedback click Screenshots or Crashes. Crash reports stay there for download for 120 days. You can turn feedback off per tester group, but keep it on.

Tell your testers about it. Most do not know the feature exists.

These two tools need no App Privacy answer. Apple says you are not responsible for disclosing data that Apple collects.

Step 2: Sentry in the app

Sentry catches JavaScript errors and native crashes, and maps the minified code back to your source files with source maps.

  1. Make the account and the project. At https://sentry.io, create an organization. Pick the data storage location (US or EU) now: you cannot change it later, only make a new organization. If your users are in the EU, pick EU. Create a React Native project. Note the DSN, the organization slug and the project slug.

  2. Make an organization token in Sentry: Settings, Developer Settings, Organization Tokens (https://docs.sentry.io/account/auth-tokens/, checked 2026-10-09). The build uses it to upload source maps. It is a secret. Put it in your secrets tool as SENTRY_AUTH_TOKEN (secrets.md).

  3. Install the SDK in the app folder:

    npx expo install @sentry/react-native

    The Sentry wizard (npx @sentry/wizard@latest -i reactNative) can do steps 4 to 6 for you. If you use it, check its output: it turns on sendDefaultPii and full tracing, and this guide turns both off.

  4. Add the config plugin in app.json:

    {
      "expo": {
        "plugins": [
          ["@sentry/react-native/expo", {
            "url": "https://sentry.io/",
            "organization": "your-org-slug",
            "project": "your-app-project-slug"
          }]
        ]
      }
    }
  5. Use Sentry’s Metro config in metro.config.js. It gives each bundle a debug ID, so Sentry matches it with its source map:

    const { getSentryExpoConfig } = require("@sentry/react-native/metro");
    module.exports = getSentryExpoConfig(__dirname);
  6. Start Sentry in the root layout (src/app/_layout.tsx):

    import * as Sentry from "@sentry/react-native";
    
    Sentry.init({
      dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
      environment: process.env.EXPO_PUBLIC_SENTRY_ENV,   // "production", "preview"
      enabled: !__DEV__,              // no events from your own development builds
      sendDefaultPii: false,          // no IP, cookies or user details from the SDK
      beforeBreadcrumb: (b) => (b.category === "console" ? null : b),   // console logs stay on the phone
    });
    
    function RootLayout() { /* your layout */ }
    export default Sentry.wrap(RootLayout);

    The DSN only lets someone send events to your project. It is not a secret, so it can live in eas.json env next to EXPO_PUBLIC_API_URL (expo-app.md).

  7. Add Sentry’s privacy manifest entries. React Native links Sentry statically, so Apple does not see Sentry’s own manifest. Add this under expo.ios in app.json, and merge it with any entries you already have:

    "privacyManifests": {
      "NSPrivacyCollectedDataTypes": [
        { "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeCrashData",
          "NSPrivacyCollectedDataTypeLinked": false, "NSPrivacyCollectedDataTypeTracking": false,
          "NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"] },
        { "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePerformanceData",
          "NSPrivacyCollectedDataTypeLinked": false, "NSPrivacyCollectedDataTypeTracking": false,
          "NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"] },
        { "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeOtherDiagnosticData",
          "NSPrivacyCollectedDataTypeLinked": false, "NSPrivacyCollectedDataTypeTracking": false,
          "NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"] }
      ],
      "NSPrivacyAccessedAPITypes": [
        { "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults", "NSPrivacyAccessedAPITypeReasons": ["CA92.1"] },
        { "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime", "NSPrivacyAccessedAPITypeReasons": ["35F9.1"] },
        { "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp", "NSPrivacyAccessedAPITypeReasons": ["C617.1"] }
      ]
    }

    ship-ios:app-store-ready checks the privacy manifest.

  8. Rebuild. The plugin changes native code. Make a new build.

Step 3: source maps from local EAS builds

The config plugin adds Xcode build steps. In a release build they upload the bundle, its source map and the debug symbols (dSYM) to Sentry. They need SENTRY_AUTH_TOKEN in the environment of the build.

eas build --local does not get EAS environment variables with “secret” visibility. It also builds from a copy of your repository, so a gitignored .env.local may not reach it. Put the token in the environment of the build command itself:

# Doppler
doppler run -- eas build --platform ios --profile production --local

# 1Password
SENTRY_AUTH_TOKEN="$(opa read op://agent-secrets/sentry/credential)" \
  eas build --platform ios --profile production --local

Debug builds skip the upload. Metro already maps the code in development.

If the token is missing or Sentry cannot be reached, the build can fail at the Sentry step. SENTRY_ALLOW_FAILURE=true lets the build go on. Then upload the maps by hand later, or the stack traces of that build stay unreadable.

If you ship JavaScript updates, upload their maps after each update. Publish with ship-ios:eas-update, not plain eas update: since SDK 55, eas update needs --environment and reads only the variables stored on EAS, not the env block in eas.json. A plain eas update then ships without EXPO_PUBLIC_API_URL and without the DSN. The skill builds the update with the same values as the build, and --source-maps keeps the maps:

<eas-update skill>/scripts/update.sh --channel production --expect-host api.example.com \
  -m "Fix the paywall typo" --publish --source-maps
SENTRY_AUTH_TOKEN=... npx @sentry/expo-upload-sourcemaps dist-update

Step 4: an error reporter for the API

Make a second Sentry project for the API (ASP.NET Core or Node). It has its own DSN.

.NET:

dotnet add package Sentry.AspNetCore
// Program.cs. The SDK reads SENTRY_DSN and SENTRY_ENVIRONMENT from the environment.
builder.WebHost.UseSentry(o =>
{
    o.SendDefaultPii = false;                                    // no user, headers or IP
    o.SetBeforeSend((e, _) => { e.ServerName = null; return e; }); // do not send the box's host name
});

Unhandled exceptions in a request are reported. ILogger entries at or above MinimumEventLevel also become events, so a caught error that you log with LogError still reaches you. Leave MaxRequestBodySize at its default, None: request bodies are never sent.

Node: npm install @sentry/node, then a file that loads before the app:

// instrument.mjs
import * as Sentry from "@sentry/node";
Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.SENTRY_ENVIRONMENT,
  sendDefaultPii: false,
});

Start the API with node --import ./instrument.mjs dist/server.js. In the Dockerfile from backend.md that is CMD ["node", "--import", "./instrument.mjs", "dist/server.js"]. Check Sentry’s page for your framework: some need one more line for their error handler.

Add both values to the API’s environment: block in docker-compose.yml:

      SENTRY_DSN: ${SENTRY_DSN:-}
      SENTRY_ENVIRONMENT: production

Staging gets SENTRY_ENVIRONMENT: staging, so you can filter it out.

In Sentry, set an alert rule for new issues in both projects. On the free plan alerts come by email.

Keep personal data out of reports

A crash report is a copy of the moment the app failed. It can hold whatever was in memory, in the URL or in the log. Rules:

In-app bug reports for testers

A crash report covers what threw. Most bugs do not throw: a wrong number, a button under the keyboard, a screen that does not refresh. A tester sees them, and a message like “the list was wrong” is hard to act on. Give admins and testers a way to send a full report from inside the app, in two taps.

The pattern:

  1. Open it. The tester shakes the phone, or taps a “Report a bug” button. Listen to the accelerometer (expo-sensors) and ask for several strong jolts within about a second, so a bump or a dropped phone does not count.
  2. Take the screenshot first. Capture the screen (react-native-view-shot) before the report sheet opens, or the screenshot shows the sheet.
  3. Collect the context. The app keeps small rolling logs from the start:
    • the current screen and the last 15 or so screens, from the navigation container;
    • the last 30 or so API calls: method, path, status and time in ms, from a wrapper around fetch; no bodies and no headers;
    • the last console warnings and errors;
    • the app version and build number, the EAS Update id, channel and runtime version (expo-updates), and the API URL;
    • the device model, iOS version, locale and time zone (expo-device).
  4. Add a note. The tester types it, or speaks it with the keyboard’s dictation key.
  5. Send it to your own API. POST /api/bug-reports with the report and the screenshot as base64.
  6. The API makes the issue. It checks that the user may report, uploads the screenshot and creates an issue in your tracker (GitHub Issues, Linear, Jira) with all the fields. The tracker’s API key lives only on the server, in your secret store (secrets.md). The app never sees it.

Rules:

Keep it as a small module in the app and one endpoint in the API. It is not worth a paid service while your testers are a handful of people.

Product analytics (only if you need it)

Crash reports tell you what broke. Analytics tell you what people do: which screens they use, where they give up. Skip it until you have a question that only analytics can answer. Every tool you add is one more processor in your privacy policy and one more App Privacy answer.

If you add one before your first public release, PostHog is a good fit. The whole setup is in product-analytics.md: the region, the key, a small wrapper with named events, the opt-in that Apple’s Guideline 5.1.1(ii) asks for, and the privacy paperwork.

App Privacy answers and privacy policy lines

What each tool adds in App Store Connect, under App Privacy:

ToolData typesLinked to the userTrackingPurpose
Xcode Organizer, TestFlightnothing to declare (Apple collects it)
Sentry in the appDiagnostics: Crash Data, Performance Data, Other Diagnostic DataNo. Yes if you call Sentry.setUser with your user idNoApp Functionality
Sentry on the APIno new type if it only sends what the API already has

If you call Sentry.setUser, also set NSPrivacyCollectedDataTypeLinked to true in the three Sentry entries of the privacy manifest. The manifest and the App Privacy answers must say the same thing.

Privacy policy lines (privacy-and-support-pages.md). Name each tool:

When the app crashes or has an error, it sends a report to Sentry, our error tracking service. Reports are stored in the [US/EU]. The report holds technical data: the error, the device model, the iOS and app version, and our internal account id. It does not hold your name or email. Reports are deleted after [30/90] days.

Our server reports its own errors to Sentry. Those reports can hold the address of the request and our internal account id, never the request content.

If you use PostHog, the wording is in product-analytics.md.

Where the values go

ValueWhere
App DSN (EXPO_PUBLIC_SENTRY_DSN), EXPO_PUBLIC_SENTRY_ENVeas.json env per build profile. Not a secret.
Organization and project slugsthe Sentry plugin entry in app.json
SENTRY_AUTH_TOKENyour secrets tool; passed to the build command. Never in the repo or the app.
API DSN (SENTRY_DSN), SENTRY_ENVIRONMENTthe API’s secrets and its environment: block in docker-compose.yml
PostHog project key and hostthe EAS production environment (product-analytics.md). Not a secret.

Check it works

Remove the test buttons before you submit.

Common errors

Wrong or out of date? Fix it on GitHub.