Operating It

Crash reporting, the handful of numbers worth watching in week one, a staged over-the-air fix, and what has to wait for a build.

Syntaxeas update --branch production --message "feed: guard an empty cursor" --rollout-percentage=10

The app is live. Operating it means knowing when it breaks, for whom, and being able to fix the JavaScript half within the hour.

Reporting

Install a crash reporter and wrap the root. Sentry's Expo integration is a config plugin plus Sentry.init and Sentry.wrap around the root layout. Turn it off in development so local crashes do not pollute the release numbers, and attach the release and the user id after sign-in, because a crash you cannot attribute to a version is a crash you cannot close.

The numbers for week one

Crash-free sessions, per platform and per version. Android ANR rate, which the Play Console reports and a crash reporter will not. Cold start time. Sign-in success rate. The API error rate by status, where a rise in 401s usually means the refresh logic, not the server. And update adoption: what share of installs is running the newest update.

Fixing it

A JavaScript or asset bug goes out as an update. Publish it to a tenth of devices first with --rollout-percentage=10, watch the crash rate for an hour, then raise it. eas update:revert-update-rollout abandons one. A rollout has to be ended before another update can be published to the same runtime version.

What has to wait

An update replaces JavaScript and assets, nothing else. A new native dependency, a changed permission string, a version bump, anything in app.config.ts that affects the native project: all of that needs a build and a submission.

Example

Example · tsx
// src/updates/UpdateBanner.tsx
// On screen: nothing at all until an update is waiting, then a thin dark
// strip at the top reading "An update is ready" with a Restart action.
// Tapping it downloads if needed and relaunches on the new bundle.
import { fetchUpdateAsync, reloadAsync, useUpdates } from "expo-updates";
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";

export default function UpdateBanner() {
  // Hooks before any return, always.
  const { isUpdateAvailable, isUpdatePending, isDownloading } = useUpdates();
  const [failed, setFailed] = useState(false);

  if (failed || (!isUpdateAvailable && !isUpdatePending)) return null;

  async function apply() {
    try {
      if (!isUpdatePending) await fetchUpdateAsync();
      await reloadAsync(); // relaunches on the downloaded update
    } catch {
      // These calls reject in a development build and with no network.
      // Hide the banner rather than show an error the user cannot act on.
      setFailed(true);
    }
  }

  return (
    <View style={styles.bar}>
      <Text style={styles.text}>An update is ready.</Text>
      <Pressable onPress={apply} disabled={isDownloading}>
        <Text style={styles.action}>{isDownloading ? "Downloading" : "Restart"}</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  // flexDirection defaults to column here, not row, so a horizontal bar
  // has to say so.
  bar: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "center",
    backgroundColor: "#111",
    paddingHorizontal: 16,
    paddingVertical: 10,
  },
  text: { color: "#fff" },
  action: { color: "#fff", fontWeight: "600" },
});

When to use it

  • Finding out within minutes that a release is crashing, and on which platform and version.
  • Shipping a JavaScript fix to ten percent of users, then to everyone once the crash rate holds.
  • Deciding whether this week's bug needs an update or a new build and a store submission.

More examples

Crash reporting in the root layout

The plugin entry in app.config.ts handles the native side and the source map upload; this is the JavaScript half. Tagging the update id is the detail that makes a staged rollout usable: when crashes appear, you can tell at a glance whether they are coming from the new bundle or the old one.

Example · tsx
// app/_layout.tsx (the reporting parts only)
import * as Sentry from "@sentry/react-native";
import { Stack } from "expo-router";
import * as Updates from "expo-updates";

Sentry.init({
  dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
  // Off locally, so development crashes stay out of release numbers.
  enabled: !__DEV__,
  sendDefaultPii: false,
  tracesSampleRate: 0.2,
});

// Which update is running. Without this, a stack trace cannot be tied to
// the bundle that produced it.
Sentry.setTag("updateId", Updates.updateId ?? "embedded");

function RootLayout() {
  return <Stack />;
}

// Sentry.wrap adds the error boundary and the navigation instrumentation.
// The default export is the wrapped component, not RootLayout.
export default Sentry.wrap(RootLayout);

The rollout, and abandoning it

A staged update is the one reversible release channel this app has, which is why a fix goes out this way rather than straight to everyone. The constraint to remember: while a rollout is in progress, no further update can be published to the same runtime version until it is ended or reverted.

Example · bash
# Publish the fix to a tenth of devices on the production branch.
eas update --branch production \
  --message "feed: guard an empty cursor" \
  --rollout-percentage=10

# Watch the crash rate for that update id. If it is clean, raise it.
eas update:edit

# If it is not, abandon it. Devices fall back to the previous update.
eas update:revert-update-rollout

# Worst case: send everyone back to the bundle inside the installed
# binary, which is the last known-good native build.
eas update:roll-back-to-embedded --branch production

# What is live, and who is running it.
eas channel:view production
eas update:list --branch production --limit 5

Events that carry no personal data

Returning the payload instead of sending it makes the filter directly testable, and the union type on name stops the event vocabulary drifting as the app grows. The blocklist is worth having before the first release: an analytics payload that quotes user content is a privacy incident that cannot be recalled, and the store questionnaires ask what you collect.

Example · tsx
// src/analytics/events.ts
type EventName = "sign_in_completed" | "entry_created" | "photo_upload_failed";
type EventProps = Record<string, string | number | boolean>;

// Anything that identifies a person, or quotes what they wrote, never
// leaves the device. Enforced in one place rather than per call site.
const BLOCKED = new Set(["email", "token", "body", "photoUrl"]);

export function safeProps(props: EventProps): EventProps {
  const out: EventProps = {};
  // Sorted, so the payload is stable and easy to compare in a test.
  for (const key of Object.keys(props).sort()) {
    if (!BLOCKED.has(key)) out[key] = props[key];
  }
  return out;
}

export function track(name: EventName, props: EventProps = {}): string {
  return JSON.stringify({ name, props: safeProps(props) });
}

console.log(track("photo_upload_failed", { entryId: "e_7312", bytes: 20480, email: "[email protected]" }));
// Output: {"name":"photo_upload_failed","props":{"bytes":20480,"entryId":"e_7312"}}

Discussion

  • Be the first to comment on this lesson.