June 16, 2026
Adding Sentry to a React Native App Without Leaking User Data
After a round of hard-to-reproduce crashes — including one where a Profile screen was crashing and hammering the API with excessive requests for other users’ profiles — it was clear that “wait for a user to report it” wasn’t a viable debugging strategy anymore. So I spent a couple of weeks wiring proper crash reporting and tracing through the NewsFeed feature, component by component.
Starting with cleanup
The first thing I found wasn’t code — it was a leftover sentry.properties file sitting in the iOS project with a plaintext auth token committed straight into source control, left over from an old Sentry CLI setup. Deleting it was step one, before adding anything new. It’s a good reminder that “add monitoring” projects are also a decent excuse to audit what’s already lying around.
Release and device context
The Sentry.init() call got more useful info attached to it — the app version and build number as the release identifier, plus device name, model, and OS version as initial scope tags:
// sentry.js
import * as Sentry from '@sentry/react-native'
import DeviceInfo from 'react-native-device-info'
import config from './config'
if (!__DEV__) {
const version = DeviceInfo.getVersion()
const buildNumber = DeviceInfo.getBuildNumber()
const systemVersion = DeviceInfo.getSystemVersion()
const deviceName = DeviceInfo.getDeviceNameSync()
const deviceModel = DeviceInfo.getModel()
Sentry.init({
dsn: config.SENTRY_DSN,
release: `${version}+STG`,
dist: buildNumber,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
initialScope: {
tags: {
'device.name': deviceName,
'device.model': deviceModel,
'os.version': systemVersion,
},
},
})
}
Before this, a crash report told you that something broke — not which build, which device, or which OS version it happened on. Small addition, large difference when triaging.
Identifying users without sending their data
Sentry needs some way to tie crash reports to a user, but sending real names or emails to a third-party error tracker isn’t something to do casually. The fix was a small Redux middleware plus a hashed identity:
// store/sentryMiddleware.js
import { setSentryUser, clearSentryUser } from '../utils/sentryUtils';
const sentryMiddleware = () => next => action => {
if (action.type === '@USER/SET_USER_INFO') {
setSentryUser(action.data?.accountInfo);
}
if (action.type === '@USER/LOG_OUT') {
clearSentryUser();
}
return next(action);
};
export default sentryMiddleware;
// utils/sentryUtils.js — user context
import * as Sentry from '@sentry/react-native';
import SHA256 from 'crypto-js/sha256';
// User ID is hashed to avoid sending personal data to Sentry.
export const setSentryUser = (accountInfo) => {
if (!accountInfo?.id) return;
Sentry.setUser({
id: SHA256(String(accountInfo.id)).toString(),
});
Sentry.setTag('tenant', accountInfo.tenantCode || accountInfo.tenant || 'unknown');
};
export const clearSentryUser = () => {
Sentry.setUser(null);
};
setSentryUser hashes the account ID with SHA-256 before it ever reaches Sentry, and tags the tenant separately. Sentry can now say “this crash happened to the same anonymous user three times” without ever knowing who that user actually is. The middleware also means user identity is never manually wired into every screen — log in, and it’s set; log out, and it’s cleared, automatically, in exactly one place.
The breadcrumb and error-capture toolkit
The rest of sentryUtils.js is a small, deliberately boring set of helpers — three breadcrumb categories, one error capture function, and the API tracer:
// ─── Breadcrumbs ────────────────────────────────────────────
export const addUIBreadcrumb = (message, data = {}) => {
Sentry.addBreadcrumb({ category: 'ui.click', message, level: 'info', data });
};
export const addNavigationBreadcrumb = (screen, data = {}) => {
Sentry.addBreadcrumb({
category: 'navigation',
message: `Navigated to ${screen}`,
level: 'info',
data,
});
};
export const addActionBreadcrumb = (message, data = {}) => {
Sentry.addBreadcrumb({ category: 'action', message, level: 'info', data });
};
// ─── Error capture ──────────────────────────────────────────
export const captureError = (error, { tag, section, screen, operation, ...extra } = {}) => {
Sentry.captureException(error?.message ?? error, {
tags: {
...(tag && { tag }),
...(section && { section }),
...(screen && { screen }),
...(operation && { operation }),
},
extra,
});
};
Wiring it into a real component
Here’s BookmarkButton.js, close to verbatim, showing the pattern that got repeated across every interactive component in the feed — breadcrumb on press, trace the API call, capture on failure:
// components/FeedButton/BookmarkButton.js
import React, { useEffect, useRef, useState } from 'react';
import { TouchableOpacity } from 'react-native';
import { useDispatch } from 'react-redux';
import { addToBookmark, removeBookmark } from '../../../state-management/newsFeed/service';
import { removeBookmarkSuccess, addBookmarkSuccess } from '../../../state-management/newsFeed/action';
import { addUIBreadcrumb, captureError, traceApiCall } from '../../../../utils/sentryUtils';
import { sentryUIBreadcrumbTags, sentryErrorTags } from '../../../../variables';
const BookmarkButton = ({ isFavorite, postId, token, item }) => {
const dispatch = useDispatch();
const [isBookmarked, setIsBookmarked] = useState(Boolean(isFavorite));
useEffect(() => {
setIsBookmarked(Boolean(isFavorite));
}, [isFavorite]);
const handlePress = () => {
addUIBreadcrumb(sentryUIBreadcrumbTags.bookmarkButtonPress, { postId, isBookmarked });
isBookmarked ? deleteBookmarkPress() : handleBookmarkPress();
setIsBookmarked(!isBookmarked);
};
const handleBookmarkPress = async () => {
try {
const params = { postId, token };
await traceApiCall('addToBookmark', () => addToBookmark(params));
dispatch(addBookmarkSuccess(item));
} catch (error) {
captureError(error, { tag: sentryErrorTags.bookmarkAdd, postId });
}
};
const deleteBookmarkPress = async () => {
try {
const params = { postId, token };
await traceApiCall('removeBookmark', () => removeBookmark(params));
dispatch(removeBookmarkSuccess(postId));
} catch (error) {
captureError(error, { tag: sentryErrorTags.bookmarkRemove, postId });
}
};
return (
<TouchableOpacity onPress={handlePress}>
{/* icon rendering omitted */}
</TouchableOpacity>
);
};
export default BookmarkButton;
PinnedButton.js follows the same shape, but with one extra wrinkle worth showing: since a pinned post can be sitting in several Redux lists at once (main feed, pinned screen, bookmarks, announcements), the pinned state gets resolved by checking all of them:
// components/FeedButton/PinnedButton.js
const isPinned = useSelector(({ newsFeedReducer }) => {
const findPost = (list) =>
list?.find((post) => post?.id === postId && post?.channel?.id === channelId);
const post =
findPost(newsFeedReducer?.feedData) ||
findPost(newsFeedReducer?.pinnedPostData) ||
findPost(newsFeedReducer?.bookmarkData) ||
findPost(newsFeedReducer?.announcementData);
return post?.channel?.isPinned;
});
const handleConfirmPin = async () => {
setShowPinModal(false);
try {
const url = postType === 2
? `mypage/channel-entry/${channelId}`
: `yui/posts/${channelId}`;
await traceApiCall('addToPinned', () => addToPinned({ url, token }));
dispatch(updatePinnedStatusNewsFeed(channelId, 1));
dispatch(updatePinnedPostData(0));
} catch (error) {
captureError(error, { tag: sentryErrorTags.pinnedButtonToggle, postId, channelId });
}
};
That findPost fallback chain is the same multi-screen state-sync problem I’ve written about before, showing up again here — and it’s exactly why captureError gets tagged with postId and channelId on every call: when something does go wrong, the report says precisely which post, in which channel, failed to toggle, not just “PinnedButton threw.”
Tracing API calls
The API-tracing helper itself:
export const traceApiCall = async (name, apiCall) => {
if (__DEV__) return apiCall();
const transaction = Sentry.startTransaction?.({ name, op: 'http.request' });
if (!transaction) return apiCall();
try {
const result = await apiCall();
transaction.setStatus('ok');
return result;
} catch (error) {
transaction.setStatus('internal_error');
throw error;
} finally {
transaction.finish();
}
};
It’s deliberately a no-op in development — there’s no reason to spend a transaction budget on a build nobody but you is running — and it fails gracefully if the SDK version in use doesn’t expose startTransaction. Wrapping BookmarkButton, PinnedButton, and the emoji picker’s reaction handlers in this caught a production crash inside traceApiCall itself, which then needed its own fix — a reminder that instrumentation code is still code, and still needs to be defensive.
Was it worth it
Unglamorous work, and it touched a lot of small files instead of one big satisfying one — EmojiPicker, CommentSection, FeedSearchBar, NewsFeedHeader, the channel/reaction/mention feed item types, the pinned post modal, all got the same treatment one at a time. But the difference between “a user says the app crashed sometimes” and “here’s the exact breadcrumb trail, device, release, and API trace for every occurrence, tagged by post and channel” is the difference between guessing and actually fixing something.