Skip to content

Commit fcf1c71

Browse files
Feat: add sentry to web-app (#1472)
* add sentry to web-app * Sentry trace rate parsing safety Co-authored-by: Copilot <[email protected]> * co pilot pr comments * deployment settings * deployment settings * update variable script to allow optional variables * sentry removed user ip and geo collection * assures the variables are set to empty for optional * code clarity * JC nitpick --------- Co-authored-by: Copilot <[email protected]>
1 parent 3c50ec6 commit fcf1c71

File tree

10 files changed

+399
-38
lines changed

10 files changed

+399
-38
lines changed

.github/workflows/web-app-deployer.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ on:
3838
description: 1Password GitHub token secret reference
3939
type: string
4040
required: false
41+
OP_SENTRY_DSN:
42+
description: Reference to the 1Password Sentry DSN secret
43+
type: string
44+
required: false
4145
ENABLE_QUALITY_CHECKS:
4246
description: When true the tests and lint checks are performed(default), false will skip. This is only valid in protopype and demo deployments
4347
type: boolean
@@ -172,6 +176,7 @@ jobs:
172176
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
173177
SLACK_WEBHOOK_URL: ${{inputs.OP_SLACK_WEBHOOK_URL}}
174178
ADD_FEED_FORM_GITHUB_TOKEN: ${{inputs.OP_ADD_FEED_FORM_GITHUB_TOKEN}}
179+
SENTRY_DSN: ${{inputs.OP_SENTRY_DSN}}
175180

176181
- name: Authenticate to Google Cloud DEV
177182
if: ${{ inputs.FIREBASE_PROJECT == 'dev' }}
@@ -235,6 +240,10 @@ jobs:
235240
echo "REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI=3600000" >> $GITHUB_ENV
236241
echo "REACT_APP_FEED_API_BASE_URL=https://api.mobilitydatabase.org" >> $GITHUB_ENV
237242
echo "REACT_APP_GBFS_VALIDATOR_API_BASE_URL=https://dev.gbfs.api.mobilitydatabase.org" >> $GITHUB_ENV
243+
echo "REACT_APP_SENTRY_DSN=${{ env.SENTRY_DSN }}" >> $GITHUB_ENV
244+
echo "REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE=0.1" >> $GITHUB_ENV
245+
echo "REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE=0.1" >> $GITHUB_ENV
246+
echo "REACT_APP_SENTRY_TRACES_SAMPLE_RATE=0.05" >> $GITHUB_ENV
238247
else
239248
echo "Setting FIREBASE_PROJECT to 'dev'"
240249
echo "FIREBASE_PROJECT=dev" >> $GITHUB_ENV
@@ -253,7 +262,7 @@ jobs:
253262
- name: Populate Variables
254263
working-directory: web-app
255264
run: |
256-
../scripts/replace-variables.sh -in_file src/.env.rename_me -out_file src/.env.${{ inputs.FIREBASE_PROJECT }} -variables REACT_APP_FIREBASE_API_KEY,REACT_APP_FIREBASE_AUTH_DOMAIN,REACT_APP_FIREBASE_PROJECT_ID,REACT_APP_FIREBASE_STORAGE_BUCKET,REACT_APP_FIREBASE_MESSAGING_SENDER_ID,REACT_APP_FIREBASE_APP_ID,REACT_APP_RECAPTCHA_SITE_KEY,REACT_APP_GOOGLE_ANALYTICS_ID,REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI,REACT_APP_FEED_API_BASE_URL,REACT_APP_GBFS_VALIDATOR_API_BASE_URL
265+
../scripts/replace-variables.sh -in_file src/.env.rename_me -out_file src/.env.${{ inputs.FIREBASE_PROJECT }} -variables REACT_APP_FIREBASE_API_KEY,REACT_APP_FIREBASE_AUTH_DOMAIN,REACT_APP_FIREBASE_PROJECT_ID,REACT_APP_FIREBASE_STORAGE_BUCKET,REACT_APP_FIREBASE_MESSAGING_SENDER_ID,REACT_APP_FIREBASE_APP_ID,REACT_APP_RECAPTCHA_SITE_KEY,REACT_APP_GOOGLE_ANALYTICS_ID,REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI,REACT_APP_FEED_API_BASE_URL,REACT_APP_GBFS_VALIDATOR_API_BASE_URL -optional_variables REACT_APP_SENTRY_DSN,REACT_APP_SENTRY_TRACES_SAMPLE_RATE,REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE,REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE
257266
258267
- name: Run Install for Functions
259268
if: ${{ inputs.DEPLOY_FIREBASE_FUNCTIONS }}

.github/workflows/web-prod.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ jobs:
1313
FEED_SUBMIT_GOOGLE_SHEET_ID: "10eIUxWVtLmc2EATiwivgXBf4bOMErOnq7GFIoRedXHU"
1414
OP_SLACK_WEBHOOK_URL: "op://rbiv7rvkkrsdlpcrz3bmv7nmcu/Slack webhook URLs/rdpfgrmnbxqaelgi5oky3lryz4/internal-add-feeds"
1515
OP_ADD_FEED_FORM_GITHUB_TOKEN: "op://rbiv7rvkkrsdlpcrz3bmv7nmcu/cwzlqlspbw7goqjsdqu4b7matq/credential"
16+
OP_SENTRY_DSN: "op://Employee/Sentry DSN - MobilityDatabase PROD/Sentry DSN PROD"
1617
secrets: inherit

scripts/replace-variables.sh

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,19 @@
2323
# For an example of a valid input file, check `../infra/vars.tfvars.rename_me`.
2424
# All variables need to be set to the environment previous running the script.
2525
# Parameters:
26-
# -variables <VARIABLES_LIST> Comma separated list of variables names.
27-
# -in_file <INPUT_FULL_PATH_NAME> Full path and file name of the input file.
28-
# -out_file <OUTPUT_FULL_PATH_NAME> Full path and file name of the output file.
29-
# -no_quotes Option to disable enclosing variable in double quotes during substitution
26+
# -variables <VARIABLES_LIST> Comma separated list of REQUIRED variable names.
27+
# -optional_variables <OPTIONAL_LIST> Comma separated list of OPTIONAL variable names (may be unset or empty).
28+
# -in_file <INPUT_FULL_PATH_NAME> Full path and file name of the input file.
29+
# -out_file <OUTPUT_FULL_PATH_NAME> Full path and file name of the output file.
30+
# -no_quotes Option to disable enclosing variable in double quotes during substitution
3031

3132
display_usage() {
3233
printf "\nThis script replaces variables from an input file creating/overriding the content of on an output file"
3334
printf "\nScript Usage:\n"
3435
echo "Usage: $0 [options]"
3536
echo "Options:"
36-
echo " -variables <VARIABLES_LIST> Comma separated list of variables names."
37+
echo " -variables <VARIABLES_LIST> Comma separated list of REQUIRED variable names."
38+
echo " -optional_variables <OPTIONAL_LIST> Comma separated list of OPTIONAL variable names."
3739
echo " -in_file <INPUT_FULL_PATH_NAME> Full path and file name of the input file."
3840
echo " -out_file <OUTPUT_FULL_PATH_NAME> Full path and file name of the output file."
3941
echo " -no_quotes Do not enclose variable values with quotes."
@@ -42,6 +44,7 @@ display_usage() {
4244
}
4345

4446
VARIABLES=""
47+
OPTIONAL_VARIABLES=""
4548
INPUT_FILE=""
4649
OUT_FILE=""
4750
ADD_QUOTES="true"
@@ -55,6 +58,11 @@ while [[ $# -gt 0 ]]; do
5558
shift # past argument
5659
shift # past value
5760
;;
61+
-optional_variables)
62+
OPTIONAL_VARIABLES="$2"
63+
shift # past argument
64+
shift # past value
65+
;;
5866
-in_file)
5967
IN_FILE="$2"
6068
shift # past argument
@@ -96,30 +104,46 @@ then
96104
fi
97105

98106
list=$(echo "$VARIABLES" | tr "," "\n")
107+
optional_list=$(echo "$OPTIONAL_VARIABLES" | tr "," "\n")
99108

100-
# Check if all variables are set
101-
for varname in $list
102-
do
103-
if [[ -z "${!varname}" ]]; then
104-
echo "Missing required variable value with name: $varname."
105-
echo "Script will not execute variables replacement, bye for now."
106-
exit 1
107-
fi
109+
# Check required variables (optional ones may be unset or empty)
110+
for varname in $list; do
111+
if [[ -z "${!varname+x}" ]]; then
112+
echo "Missing required variable (unset) with name: $varname."
113+
echo "Script will not execute variables replacement, bye for now."
114+
exit 1
115+
fi
116+
if [[ -z "${!varname}" ]]; then
117+
echo "Missing required variable (empty value) with name: $varname."
118+
echo "Script will not execute variables replacement, bye for now."
119+
exit 1
120+
fi
108121
done
109122

110123
# Reads from input setting the first version of the output.
111124
output=$(<"$IN_FILE")
112125

113126
# Replace variables and create output file
114-
for varname in $list
115-
do
116-
# shellcheck disable=SC2001
117-
# shellcheck disable=SC2016
118-
if [[ "$ADD_QUOTES" == "true" ]]; then
119-
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'\""${!varname}"\"'|g')
120-
else
121-
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"${!varname}"'|g')
122-
fi
127+
for varname in $list; do
128+
# Required variables are guaranteed non-empty here
129+
value="${!varname}"
130+
# shellcheck disable=SC2001
131+
# shellcheck disable=SC2016
132+
if [[ "$ADD_QUOTES" == "true" ]]; then
133+
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"\"$value\""'|g')
134+
else
135+
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"$value"'|g')
136+
fi
137+
done
138+
139+
# Substitute optional variables
140+
for varname in $optional_list; do
141+
value="${!varname}"
142+
if [[ "$ADD_QUOTES" == "true" ]]; then
143+
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"\"$value\""'|g')
144+
else
145+
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"$value"'|g')
146+
fi
123147
done
124148

125149
echo "$output" > "$OUT_FILE"

web-app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"@mui/x-date-pickers": "^7.11.0",
1111
"@mui/x-tree-view": "^6.17.0",
1212
"@reduxjs/toolkit": "^1.9.6",
13+
"@sentry/react": "^10.26.0",
1314
"@turf/center": "^6.5.0",
1415
"@types/i18next": "^13.0.0",
1516
"@types/leaflet": "^1.9.12",

web-app/src/.env.rename_me

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ REACT_APP_GOOGLE_ANALYTICS_ID={{REACT_APP_GOOGLE_ANALYTICS_ID}}
1010
REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI={{REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI}}
1111
REACT_APP_FEED_API_BASE_URL={{REACT_APP_FEED_API_BASE_URL}}
1212
REACT_APP_GBFS_VALIDATOR_API_BASE_URL={{REACT_APP_GBFS_VALIDATOR_API_BASE_URL}}
13+
REACT_APP_SENTRY_DSN={{REACT_APP_SENTRY_DSN}}
14+
REACT_APP_SENTRY_TRACES_SAMPLE_RATE={{REACT_APP_SENTRY_TRACES_SAMPLE_RATE}}
15+
REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE={{REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE}}
16+
REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE={{REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE}}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import React, { useState } from 'react';
2+
import {
3+
Box,
4+
Button,
5+
Collapse,
6+
Typography,
7+
Stack,
8+
Card,
9+
CardContent,
10+
Alert,
11+
} from '@mui/material';
12+
13+
export interface SentryErrorFallbackProps {
14+
error: unknown;
15+
eventId?: string;
16+
resetError?: () => void;
17+
}
18+
19+
const formatError = (error: unknown): string => {
20+
if (error instanceof Error) {
21+
return `${error.message}\n${error.stack}`;
22+
}
23+
try {
24+
return typeof error === 'string' ? error : JSON.stringify(error, null, 2);
25+
} catch {
26+
return String(error);
27+
}
28+
};
29+
30+
export const SentryErrorFallback: React.FC<SentryErrorFallbackProps> = ({
31+
error,
32+
eventId,
33+
resetError,
34+
}) => {
35+
const [showDetails, setShowDetails] = useState(false);
36+
const details = formatError(error);
37+
return (
38+
<Box sx={{ p: 4, maxWidth: 760, m: '40px auto' }}>
39+
<Card variant='outlined'>
40+
<CardContent>
41+
<Stack spacing={2}>
42+
<Alert severity='error' variant='outlined'>
43+
<Typography
44+
variant='h5'
45+
component='h2'
46+
sx={{ mb: 1, fontWeight: 600 }}
47+
>
48+
Something went wrong
49+
</Typography>
50+
<Typography variant='body2'>
51+
Our team has been notified. You can try reloading or attempt to
52+
recover.
53+
</Typography>
54+
</Alert>
55+
{eventId != null && (
56+
<Typography variant='caption' color='text.secondary'>
57+
Event ID: {eventId}
58+
</Typography>
59+
)}
60+
<Stack direction='row' spacing={1}>
61+
<Button
62+
variant='contained'
63+
color='error'
64+
onClick={() => {
65+
window.location.reload();
66+
}}
67+
>
68+
Reload Page
69+
</Button>
70+
{resetError != null && (
71+
<Button variant='outlined' color='primary' onClick={resetError}>
72+
Try Again
73+
</Button>
74+
)}
75+
<Button
76+
variant='text'
77+
color='inherit'
78+
onClick={() => {
79+
setShowDetails((v) => !v);
80+
}}
81+
>
82+
{showDetails ? 'Hide Details' : 'Show Details'}
83+
</Button>
84+
</Stack>
85+
<Collapse in={showDetails} unmountOnExit>
86+
<Box
87+
sx={{
88+
mt: 1,
89+
p: 2,
90+
bgcolor: 'background.default',
91+
borderRadius: 1,
92+
fontFamily: 'monospace',
93+
fontSize: 12,
94+
whiteSpace: 'pre-wrap',
95+
wordBreak: 'break-word',
96+
maxHeight: 300,
97+
overflow: 'auto',
98+
border: '1px solid',
99+
borderColor: 'divider',
100+
}}
101+
>
102+
{details}
103+
</Box>
104+
</Collapse>
105+
</Stack>
106+
</CardContent>
107+
</Card>
108+
</Box>
109+
);
110+
};
111+
112+
export default SentryErrorFallback;

web-app/src/app/store/store.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import createSagaMiddleware from '@redux-saga/core';
1717
import rootSaga from './saga/root-saga';
1818

1919
import rootReducer from './reducers';
20+
import { createReduxEnhancer } from '@sentry/react';
2021

2122
const persistConfig = {
2223
key: 'root',
@@ -29,19 +30,50 @@ const persistedReducer = persistReducer(persistConfig, rootReducer);
2930

3031
const sagaMiddleware = createSagaMiddleware();
3132

32-
export const store = configureStore({
33-
reducer: persistedReducer,
34-
devTools: process.env.NODE_ENV !== 'production',
35-
middleware: (getDefaultMiddleware) => [
36-
...getDefaultMiddleware({
37-
serializableCheck: {
38-
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
39-
},
40-
}),
41-
sagaMiddleware,
42-
],
33+
// Light-weight state sanitizer used by Sentry redux enhancer
34+
const sanitizeState = (state: unknown): unknown => {
35+
if (state == null || typeof state !== 'object') {
36+
return state;
37+
}
38+
const copy: Record<string, unknown> = {};
39+
for (const [k, v] of Object.entries(state)) {
40+
if (v == null) {
41+
copy[k] = v;
42+
} else if (Array.isArray(v)) {
43+
copy[k] = { __type: 'array', length: v.length };
44+
} else if (typeof v === 'object') {
45+
copy[k] = { __type: 'object', keys: Object.keys(v).length };
46+
} else {
47+
copy[k] = v;
48+
}
49+
}
50+
return copy;
51+
};
52+
53+
const sentryReduxEnhancer = createReduxEnhancer({
54+
attachReduxState: true,
55+
stateTransformer: sanitizeState,
4356
});
4457

58+
/* eslint-disable */
59+
const makeStore = () =>
60+
configureStore({
61+
reducer: persistedReducer,
62+
devTools: process.env.NODE_ENV !== 'production',
63+
middleware: (getDefaultMiddleware) => [
64+
...getDefaultMiddleware({
65+
serializableCheck: {
66+
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
67+
},
68+
}),
69+
sagaMiddleware,
70+
],
71+
enhancers: (existing) => [...existing, sentryReduxEnhancer],
72+
});
73+
/* eslint-enable */
74+
75+
export const store = makeStore();
76+
4577
// Expose store to Cypress e2e tests
4678
/* eslint-disable */
4779
if (window.Cypress) {

web-app/src/index.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import './sentry';
2+
import { SentryErrorBoundary } from './sentry';
3+
import SentryErrorFallback from './app/components/SentryErrorFallback';
14
import React from 'react';
25
import ReactDOM from 'react-dom/client';
36
import './index.css';
@@ -22,7 +25,18 @@ root.render(
2225
<ThemeProvider>
2326
<CssBaseline />
2427
<ContextProviders>
25-
<App />
28+
<SentryErrorBoundary
29+
fallback={({ error, eventId, resetError }) => (
30+
<SentryErrorFallback
31+
error={error}
32+
eventId={eventId}
33+
resetError={resetError}
34+
/>
35+
)}
36+
showDialog
37+
>
38+
<App />
39+
</SentryErrorBoundary>
2640
</ContextProviders>
2741
</ThemeProvider>
2842
</React.StrictMode>

0 commit comments

Comments
 (0)