Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/web-app-deployer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ on:
description: 1Password GitHub token secret reference
type: string
required: false
OP_SENTRY_DSN:
description: Reference to the 1Password Sentry DSN secret
type: string
required: false
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The OP_SENTRY_DSN input parameter is marked as required: false, but in production it's expected to be provided. Consider making this required or adding validation in the workflow to ensure it's provided for production deployments. Without the DSN, Sentry won't function, which could lead to silent failures in error tracking.

Suggested change
required: false
required: true

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fine due to the qa and dev environment not requiring it

ENABLE_QUALITY_CHECKS:
description: When true the tests and lint checks are performed(default), false will skip. This is only valid in protopype and demo deployments
type: boolean
Expand Down Expand Up @@ -172,6 +176,7 @@ jobs:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
SLACK_WEBHOOK_URL: ${{inputs.OP_SLACK_WEBHOOK_URL}}
ADD_FEED_FORM_GITHUB_TOKEN: ${{inputs.OP_ADD_FEED_FORM_GITHUB_TOKEN}}
SENTRY_DSN: ${{inputs.OP_SENTRY_DSN}}

- name: Authenticate to Google Cloud DEV
if: ${{ inputs.FIREBASE_PROJECT == 'dev' }}
Expand Down Expand Up @@ -235,6 +240,10 @@ jobs:
echo "REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI=3600000" >> $GITHUB_ENV
echo "REACT_APP_FEED_API_BASE_URL=https://api.mobilitydatabase.org" >> $GITHUB_ENV
echo "REACT_APP_GBFS_VALIDATOR_API_BASE_URL=https://dev.gbfs.api.mobilitydatabase.org" >> $GITHUB_ENV
echo "REACT_APP_SENTRY_DSN=${{ env.SENTRY_DSN }}" >> $GITHUB_ENV
echo "REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE=0.1" >> $GITHUB_ENV
echo "REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE=0.1" >> $GITHUB_ENV
echo "REACT_APP_SENTRY_TRACES_SAMPLE_RATE=0.05" >> $GITHUB_ENV
Comment on lines +243 to +246
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The sample rates for Sentry are only configured in the production environment (within the if block), but they're not set for the dev environment (in the else block). If Sentry DSN is somehow provided in development, it will use the default values from sentry.ts. For consistency and to avoid confusion, consider explicitly setting these values in both environments or documenting why they're production-only.

Copilot uses AI. Check for mistakes.
else
echo "Setting FIREBASE_PROJECT to 'dev'"
echo "FIREBASE_PROJECT=dev" >> $GITHUB_ENV
Expand All @@ -253,7 +262,7 @@ jobs:
- name: Populate Variables
working-directory: web-app
run: |
../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
../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

- name: Run Install for Functions
if: ${{ inputs.DEPLOY_FIREBASE_FUNCTIONS }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/web-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ jobs:
FEED_SUBMIT_GOOGLE_SHEET_ID: "10eIUxWVtLmc2EATiwivgXBf4bOMErOnq7GFIoRedXHU"
OP_SLACK_WEBHOOK_URL: "op://rbiv7rvkkrsdlpcrz3bmv7nmcu/Slack webhook URLs/rdpfgrmnbxqaelgi5oky3lryz4/internal-add-feeds"
OP_ADD_FEED_FORM_GITHUB_TOKEN: "op://rbiv7rvkkrsdlpcrz3bmv7nmcu/cwzlqlspbw7goqjsdqu4b7matq/credential"
OP_SENTRY_DSN: "op://Employee/Sentry DSN - MobilityDatabase PROD/Sentry DSN PROD"
secrets: inherit
68 changes: 46 additions & 22 deletions scripts/replace-variables.sh
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this script was modified to accept optional_variables as an optional parameter. This script is used in a few place, these modifications shouldn't affect anything. Optional parameters are used to set env variables for env specific (ex: Sentry just for PROD)

Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,19 @@
# For an example of a valid input file, check `../infra/vars.tfvars.rename_me`.
# All variables need to be set to the environment previous running the script.
# Parameters:
# -variables <VARIABLES_LIST> Comma separated list of variables names.
# -in_file <INPUT_FULL_PATH_NAME> Full path and file name of the input file.
# -out_file <OUTPUT_FULL_PATH_NAME> Full path and file name of the output file.
# -no_quotes Option to disable enclosing variable in double quotes during substitution
# -variables <VARIABLES_LIST> Comma separated list of REQUIRED variable names.
# -optional_variables <OPTIONAL_LIST> Comma separated list of OPTIONAL variable names (may be unset or empty).
# -in_file <INPUT_FULL_PATH_NAME> Full path and file name of the input file.
# -out_file <OUTPUT_FULL_PATH_NAME> Full path and file name of the output file.
# -no_quotes Option to disable enclosing variable in double quotes during substitution

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

VARIABLES=""
OPTIONAL_VARIABLES=""
INPUT_FILE=""
OUT_FILE=""
ADD_QUOTES="true"
Expand All @@ -55,6 +58,11 @@ while [[ $# -gt 0 ]]; do
shift # past argument
shift # past value
;;
-optional_variables)
OPTIONAL_VARIABLES="$2"
shift # past argument
shift # past value
;;
-in_file)
IN_FILE="$2"
shift # past argument
Expand Down Expand Up @@ -96,30 +104,46 @@ then
fi

list=$(echo "$VARIABLES" | tr "," "\n")
optional_list=$(echo "$OPTIONAL_VARIABLES" | tr "," "\n")

# Check if all variables are set
for varname in $list
do
if [[ -z "${!varname}" ]]; then
echo "Missing required variable value with name: $varname."
echo "Script will not execute variables replacement, bye for now."
exit 1
fi
# Check required variables (optional ones may be unset or empty)
for varname in $list; do
if [[ -z "${!varname+x}" ]]; then
echo "Missing required variable (unset) with name: $varname."
echo "Script will not execute variables replacement, bye for now."
exit 1
fi
if [[ -z "${!varname}" ]]; then
echo "Missing required variable (empty value) with name: $varname."
echo "Script will not execute variables replacement, bye for now."
exit 1
fi
done

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

# Replace variables and create output file
for varname in $list
do
# shellcheck disable=SC2001
# shellcheck disable=SC2016
if [[ "$ADD_QUOTES" == "true" ]]; then
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'\""${!varname}"\"'|g')
else
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"${!varname}"'|g')
fi
for varname in $list; do
# Required variables are guaranteed non-empty here
value="${!varname}"
# shellcheck disable=SC2001
# shellcheck disable=SC2016
if [[ "$ADD_QUOTES" == "true" ]]; then
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"\"$value\""'|g')
else
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"$value"'|g')
fi
done

# Substitute optional variables
for varname in $optional_list; do
value="${!varname}"
if [[ "$ADD_QUOTES" == "true" ]]; then
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"\"$value\""'|g')
else
output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"$value"'|g')
fi
done

echo "$output" > "$OUT_FILE"
1 change: 1 addition & 0 deletions web-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@mui/x-date-pickers": "^7.11.0",
"@mui/x-tree-view": "^6.17.0",
"@reduxjs/toolkit": "^1.9.6",
"@sentry/react": "^10.26.0",
"@turf/center": "^6.5.0",
"@types/i18next": "^13.0.0",
"@types/leaflet": "^1.9.12",
Expand Down
4 changes: 4 additions & 0 deletions web-app/src/.env.rename_me
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ REACT_APP_GOOGLE_ANALYTICS_ID={{REACT_APP_GOOGLE_ANALYTICS_ID}}
REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI={{REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI}}
REACT_APP_FEED_API_BASE_URL={{REACT_APP_FEED_API_BASE_URL}}
REACT_APP_GBFS_VALIDATOR_API_BASE_URL={{REACT_APP_GBFS_VALIDATOR_API_BASE_URL}}
REACT_APP_SENTRY_DSN={{REACT_APP_SENTRY_DSN}}
REACT_APP_SENTRY_TRACES_SAMPLE_RATE={{REACT_APP_SENTRY_TRACES_SAMPLE_RATE}}
REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE={{REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE}}
REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE={{REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE}}
112 changes: 112 additions & 0 deletions web-app/src/app/components/SentryErrorFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import {
Box,
Button,
Collapse,
Typography,
Stack,
Card,
CardContent,
Alert,
} from '@mui/material';

export interface SentryErrorFallbackProps {
error: unknown;
eventId?: string;
resetError?: () => void;
}

const formatError = (error: unknown): string => {
if (error instanceof Error) {
return `${error.message}\n${error.stack}`;
}
try {
return typeof error === 'string' ? error : JSON.stringify(error, null, 2);
} catch {
return String(error);
}
};
Comment on lines +19 to +28
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error formatting logic in formatError doesn't handle circular references in objects. If the error object contains circular references, JSON.stringify(error, null, 2) will throw a TypeError. Consider using a safe stringify utility or adding a replacer function to handle circular references:

const formatError = (error: unknown): string => {
  if (error instanceof Error) {
    return `${error.message}\n${error.stack}`;
  }
  try {
    const seen = new WeakSet();
    return typeof error === 'string' ? error : JSON.stringify(error, (key, value) => {
      if (typeof value === 'object' && value !== null) {
        if (seen.has(value)) {
          return '[Circular]';
        }
        seen.add(value);
      }
      return value;
    }, 2);
  } catch {
    return String(error);
  }
};

Copilot uses AI. Check for mistakes.

export const SentryErrorFallback: React.FC<SentryErrorFallbackProps> = ({
error,
eventId,
resetError,
}) => {
const [showDetails, setShowDetails] = useState(false);
const details = formatError(error);
return (
<Box sx={{ p: 4, maxWidth: 760, m: '40px auto' }}>
<Card variant='outlined'>
<CardContent>
<Stack spacing={2}>
<Alert severity='error' variant='outlined'>
<Typography
variant='h5'
component='h2'
sx={{ mb: 1, fontWeight: 600 }}
>
Something went wrong
</Typography>
<Typography variant='body2'>
Our team has been notified. You can try reloading or attempt to
recover.
</Typography>
</Alert>
{eventId != null && (
<Typography variant='caption' color='text.secondary'>
Event ID: {eventId}
</Typography>
)}
<Stack direction='row' spacing={1}>
<Button
variant='contained'
color='error'
onClick={() => {
window.location.reload();
}}
>
Reload Page
</Button>
{resetError != null && (
<Button variant='outlined' color='primary' onClick={resetError}>
Try Again
</Button>
)}
<Button
variant='text'
color='inherit'
onClick={() => {
setShowDetails((v) => !v);
}}
>
{showDetails ? 'Hide Details' : 'Show Details'}
</Button>
</Stack>
<Collapse in={showDetails} unmountOnExit>
<Box
sx={{
mt: 1,
p: 2,
bgcolor: 'background.default',
borderRadius: 1,
fontFamily: 'monospace',
fontSize: 12,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 300,
overflow: 'auto',
border: '1px solid',
borderColor: 'divider',
}}
>
{details}
</Box>
</Collapse>
</Stack>
</CardContent>
</Card>
</Box>
);
};

export default SentryErrorFallback;
54 changes: 43 additions & 11 deletions web-app/src/app/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import createSagaMiddleware from '@redux-saga/core';
import rootSaga from './saga/root-saga';

import rootReducer from './reducers';
import { createReduxEnhancer } from '@sentry/react';

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

const sagaMiddleware = createSagaMiddleware();

export const store = configureStore({
reducer: persistedReducer,
devTools: process.env.NODE_ENV !== 'production',
middleware: (getDefaultMiddleware) => [
...getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
sagaMiddleware,
],
// Light-weight state sanitizer used by Sentry redux enhancer
const sanitizeState = (state: unknown): unknown => {
if (state == null || typeof state !== 'object') {
return state;
}
const copy: Record<string, unknown> = {};
for (const [k, v] of Object.entries(state)) {
if (v == null) {
copy[k] = v;
} else if (Array.isArray(v)) {
copy[k] = { __type: 'array', length: v.length };
} else if (typeof v === 'object') {
copy[k] = { __type: 'object', keys: Object.keys(v).length };
} else {
copy[k] = v;
}
}
return copy;
};

const sentryReduxEnhancer = createReduxEnhancer({
attachReduxState: true,
stateTransformer: sanitizeState,
});

/* eslint-disable */
const makeStore = () =>
configureStore({
reducer: persistedReducer,
devTools: process.env.NODE_ENV !== 'production',
middleware: (getDefaultMiddleware) => [
...getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
sagaMiddleware,
],
enhancers: (existing) => [...existing, sentryReduxEnhancer],
});
/* eslint-enable */

export const store = makeStore();

// Expose store to Cypress e2e tests
/* eslint-disable */
if (window.Cypress) {
Expand Down
16 changes: 15 additions & 1 deletion web-app/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import './sentry';
import { SentryErrorBoundary } from './sentry';
import SentryErrorFallback from './app/components/SentryErrorFallback';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
Expand All @@ -22,7 +25,18 @@ root.render(
<ThemeProvider>
<CssBaseline />
<ContextProviders>
<App />
<SentryErrorBoundary
fallback={({ error, eventId, resetError }) => (
<SentryErrorFallback
error={error}
eventId={eventId}
resetError={resetError}
/>
)}
showDialog
>
<App />
</SentryErrorBoundary>
</ContextProviders>
</ThemeProvider>
</React.StrictMode>
Expand Down
Loading
Loading