Skip to content

Commit 7be92aa

Browse files
authored
Merge pull request #1875 from jetstreamapp/fix/error-tracker-fixes
fix: enhance error tracking and handling for Salesforce API interactions
2 parents 9131bf8 + f6f65d1 commit 7be92aa

25 files changed

Lines changed: 345 additions & 62 deletions

File tree

apps/api/src/app/utils/response.handlers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res:
166166
sfdcErrorMessage === ERROR_MESSAGES.SFDC_EXPIRED_TOKEN ||
167167
sfdcErrorMessage === ERROR_MESSAGES.SFDC_EXPIRED_SESSION ||
168168
sfdcErrorMessage === ERROR_MESSAGES.SFDC_EXPIRED_TOKEN_VALIDITY ||
169+
sfdcErrorMessage === ERROR_MESSAGES.SFDC_INVALID_SESSION_ID ||
169170
ERROR_MESSAGES.SFDC_ORG_DOES_NOT_EXIST.test(sfdcErrorMessage);
170171
const isSfdcRestApiNotEnabled = ERROR_MESSAGES.SFDC_REST_API_NOT_ENABLED.test(sfdcErrorMessage);
171172
// Salesforce reports the same auth failure with different HTTP statuses depending on the API (REST is

libs/features/automation-control/src/useAutomationControlData.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,9 @@ export function useAutomationControlData({
751751
dispatch({ type: 'FETCH_SUCCESS', payload: item });
752752
} else {
753753
dispatch({ type: 'FETCH_ERROR', payload: item });
754-
tracker.error('Automation Control Fetch Error', { item });
754+
// Pass the underlying error as an Error so distinct causes get distinct fingerprints and the
755+
// tracker's ignore list (e.g. expired-token noise) can match it — extras are not inspected.
756+
tracker.error('Automation Control Fetch Error', new Error(item.error), { item });
755757
}
756758
},
757759
error: (err) => {

libs/features/load-records/src/components/load-mapping-storage/LoadMappingPopover.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { logger } from '@jetstream/shared/client-logger';
33
import { LoadSavedMappingItem } from '@jetstream/types';
44
import { BadgeNotification, EmptyState, fireToast, Icon, Popover, PopoverRef } from '@jetstream/ui';
55
import { STATIC_MAPPING_PREFIX } from '@jetstream/ui-core';
6-
import { dexieDb } from '@jetstream/ui/db';
6+
import { dexieDb, withReopenOnDatabaseClosed } from '@jetstream/ui/db';
77
import classNames from 'classnames';
88
import { useLiveQuery } from 'dexie-react-hooks';
99
import { FunctionComponent, useRef } from 'react';
@@ -46,10 +46,10 @@ export const LoadMappingPopover: FunctionComponent<LoadMappingPopoverProps> = ({
4646
}
4747
}
4848

49-
function handleButtonAction(id: string, metadata: LoadSavedMappingItem) {
49+
async function handleButtonAction(id: string, metadata: LoadSavedMappingItem) {
5050
try {
5151
if (id === 'delete' && metadata) {
52-
dexieDb.load_saved_mapping.where('key').equals(metadata.key).delete();
52+
await withReopenOnDatabaseClosed(() => dexieDb.load_saved_mapping.where('key').equals(metadata.key).delete());
5353
}
5454
} catch (ex) {
5555
logger.warn('Failed to delete field mapping', ex);

libs/features/load-records/src/components/load-mapping-storage/SaveMappingPopover.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { css } from '@emotion/react';
2+
import { logger } from '@jetstream/shared/client-logger';
23
import { formatNumber } from '@jetstream/shared/ui-utils';
34
import { pluralizeIfMultiple } from '@jetstream/shared/utils';
45
import { FieldMapping, LoadSavedMappingItem } from '@jetstream/types';
5-
import { Grid, Icon, Input, Popover, PopoverRef, ScopedNotification } from '@jetstream/ui';
6-
import { dexieDb, getHashedRecordKey } from '@jetstream/ui/db';
6+
import { fireToast, Grid, Icon, Input, Popover, PopoverRef, ScopedNotification } from '@jetstream/ui';
7+
import { dexieDb, getHashedRecordKey, withReopenOnDatabaseClosed } from '@jetstream/ui/db';
78
import { formatISO } from 'date-fns/formatISO';
89
import omit from 'lodash/omit';
910
import { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react';
@@ -55,14 +56,24 @@ export const SaveMappingPopover: FunctionComponent<SaveMappingPopoverProps> = ({
5556
}, [fieldMapping, sobject]);
5657

5758
async function handleSave() {
58-
const newMapping: LoadSavedMappingItem = { ...currentSavedMapping, name: mappingName };
59-
newMapping.createdAt = new Date();
60-
newMapping.key = `lsm_${sobject}:${newMapping.csvFields.length}:${formatISO(newMapping.createdAt).toLowerCase()}`;
61-
newMapping.hashedKey = await getHashedRecordKey(newMapping.key);
62-
dexieDb.load_saved_mapping.put(newMapping);
63-
saveSetMappingName('');
64-
setCurrentSavedMapping(getDefaultItem(sobject));
65-
popoverRef.current?.close();
59+
try {
60+
const newMapping: LoadSavedMappingItem = { ...currentSavedMapping, name: mappingName };
61+
newMapping.createdAt = new Date();
62+
newMapping.key = `lsm_${sobject}:${newMapping.csvFields.length}:${formatISO(newMapping.createdAt).toLowerCase()}`;
63+
newMapping.hashedKey = await getHashedRecordKey(newMapping.key);
64+
await withReopenOnDatabaseClosed(() => dexieDb.load_saved_mapping.put(newMapping));
65+
saveSetMappingName('');
66+
setCurrentSavedMapping(getDefaultItem(sobject));
67+
popoverRef.current?.close();
68+
} catch (ex) {
69+
// The awaited Dexie write can reject; handleSave is called fire-and-forget from onSubmit, so catch
70+
// here to avoid an unhandled rejection and give the user feedback (popover stays open to retry).
71+
logger.warn('Failed to save field mapping', ex);
72+
fireToast({
73+
type: 'error',
74+
message: 'Uh oh, there was a problem saving the field mapping. Please try again.',
75+
});
76+
}
6677
}
6778

6879
return (

libs/features/load-records/src/components/load-results/LoadRecordsBulkApiResults.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,11 @@ export const LoadRecordsBulkApiResults = ({
462462
body: `❌ ${getErrorMessage(ex)}`,
463463
tag: 'load-records',
464464
});
465-
tracker.error('Error preparing bulk api data', ex);
465+
// A user-initiated abort throws 'Aborted' through this same path — keep the UI messaging but
466+
// don't report it as an application error.
467+
if (!isAborted.current) {
468+
tracker.error('Error preparing bulk api data', ex);
469+
}
466470
return;
467471
}
468472
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -516,12 +520,19 @@ export const LoadRecordsBulkApiResults = ({
516520
tag: 'load-records',
517521
});
518522
}
519-
tracker.error('Error loading batches', loadError, {
520-
specificErrors: loadError.additionalErrors.map((error) => ({
521-
message: error.message,
522-
stack: error.stack,
523-
})),
524-
});
523+
// A user-initiated abort surfaces through the same loadError path — keep the UI messaging but
524+
// don't report it as an application error unless some batch failed for a non-abort reason.
525+
const abortMessagePattern = /aborted by user|data load was aborted|current job state is 'Aborted'/i;
526+
const onlyUserAbortErrors =
527+
loadError.additionalErrors.length > 0 && loadError.additionalErrors.every((error) => abortMessagePattern.test(error.message));
528+
if (!onlyUserAbortErrors) {
529+
tracker.error('Error loading batches', loadError, {
530+
specificErrors: loadError.additionalErrors.map((error) => ({
531+
message: error.message,
532+
stack: error.stack,
533+
})),
534+
});
535+
}
525536
} else {
526537
setJobInfo(jobInfo);
527538
setStatus(STATUSES.PROCESSING);

libs/features/load-records/src/utils/__tests__/load-records-process.spec.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,49 @@
1-
import { MAX_CONSECUTIVE_FAILURES, isFatalBulkApiError } from '../load-records-process';
1+
import { MAX_BATCH_CSV_CHARS, MAX_CONSECUTIVE_FAILURES, generateSizeCappedBatchCsvs, isFatalBulkApiError } from '../load-records-process';
22

33
describe('MAX_CONSECUTIVE_FAILURES', () => {
44
it('should equal 5', () => {
55
expect(MAX_CONSECUTIVE_FAILURES).toBe(5);
66
});
77
});
88

9+
describe('generateSizeCappedBatchCsvs', () => {
10+
it('splits by record count when all batches are within the size cap', () => {
11+
const records = Array.from({ length: 10 }, (_, i) => ({ Id: `rec-${i}`, Name: `Record ${i}` }));
12+
13+
const csvs = generateSizeCappedBatchCsvs(records, 3);
14+
15+
expect(csvs).toHaveLength(4);
16+
// Every record lands in exactly one batch and each batch repeats the header
17+
records.forEach(({ Id }) => {
18+
expect(csvs.filter((csv) => csv.includes(Id))).toHaveLength(1);
19+
});
20+
csvs.forEach((csv) => expect(csv.startsWith('Id')).toBe(true));
21+
});
22+
23+
it('halves batches whose CSV exceeds the character cap', () => {
24+
// 8 records x ~2M chars each = ~16M chars in one count-based batch, which must split into two
25+
const bigValue = 'x'.repeat(2_000_000);
26+
const records = Array.from({ length: 8 }, (_, i) => ({ Id: `rec-${i}`, Description: bigValue }));
27+
28+
const csvs = generateSizeCappedBatchCsvs(records, 8);
29+
30+
expect(csvs.length).toBeGreaterThan(1);
31+
csvs.forEach((csv) => expect(csv.length).toBeLessThanOrEqual(MAX_BATCH_CSV_CHARS));
32+
records.forEach(({ Id }) => {
33+
expect(csvs.filter((csv) => csv.includes(`${Id},`))).toHaveLength(1);
34+
});
35+
});
36+
37+
it('passes through a single record that alone exceeds the cap', () => {
38+
const records = [{ Id: 'rec-0', Description: 'x'.repeat(MAX_BATCH_CSV_CHARS + 100) }];
39+
40+
const csvs = generateSizeCappedBatchCsvs(records, 100);
41+
42+
expect(csvs).toHaveLength(1);
43+
expect(csvs[0].length).toBeGreaterThan(MAX_BATCH_CSV_CHARS);
44+
});
45+
});
46+
947
describe('isFatalBulkApiError', () => {
1048
describe('fatal error patterns', () => {
1149
it('should detect ApiBatchItems Limit exceeded', () => {

libs/features/load-records/src/utils/load-records-process.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,34 @@ export const FATAL_BULK_ERROR_PATTERNS: ReadonlyArray<RegExp> = Object.freeze([
6969

7070
export const MAX_CONSECUTIVE_FAILURES = 5;
7171

72+
// Salesforce Bulk API v1 rejects any batch CSV over 10,000,000 characters ("Failed to read request.
73+
// Exceeded max size limit of 10000000"). Batching is by record count, so wide rows can blow the cap
74+
// even at modest batch sizes. Kept below the hard limit for encoding/newline headroom.
75+
export const MAX_BATCH_CSV_CHARS = 9_500_000;
76+
7277
export function isFatalBulkApiError(error: unknown): boolean {
7378
const message = getErrorMessage(error);
7479
return FATAL_BULK_ERROR_PATTERNS.some((pattern) => pattern.test(message));
7580
}
7681

82+
/**
83+
* Generate batch CSVs capped both by record count and by CSV character count. A batch whose CSV
84+
* exceeds `MAX_BATCH_CSV_CHARS` is recursively halved until it fits; a single record that alone
85+
* exceeds the cap is passed through as-is (Salesforce rejects it with a per-batch error, same as today).
86+
*/
87+
export function generateSizeCappedBatchCsvs(records: unknown[], batchSize: number): string[] {
88+
return splitArrayToMaxSize(records, batchSize).flatMap(csvForBatchRecords);
89+
}
90+
91+
function csvForBatchRecords(batchRecords: unknown[]): string[] {
92+
const csv = generateCsv(batchRecords, { delimiter: ',' });
93+
if (csv.length <= MAX_BATCH_CSV_CHARS || batchRecords.length <= 1) {
94+
return [csv];
95+
}
96+
const midpoint = Math.ceil(batchRecords.length / 2);
97+
return [...csvForBatchRecords(batchRecords.slice(0, midpoint)), ...csvForBatchRecords(batchRecords.slice(midpoint))];
98+
}
99+
77100
/**
78101
* Load data using the BULK API
79102
*
@@ -91,9 +114,7 @@ export async function loadBulkApiData(
91114
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
92115
const jobId = results.id!;
93116
let batches: LoadDataBulkApi[] = [];
94-
batches = splitArrayToMaxSize(data, batchSize)
95-
.map((batch) => generateCsv(batch, { delimiter: ',' }))
96-
.map((data, i) => ({ data, batchNumber: i, completed: false, success: false }));
117+
batches = generateSizeCappedBatchCsvs(data, batchSize).map((data, i) => ({ data, batchNumber: i, completed: false, success: false }));
97118

98119
let submittedBatchCount = 0;
99120

libs/features/manage-permissions/src/usePermissionRecords.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,8 @@ export function usePermissionRecords(selectedOrg: SalesforceOrgUi, sobjects: str
138138
setSystemPermissionMap(output.systemPermissionMap);
139139
}
140140
} catch (ex) {
141-
logger.warn('[useProfilesAndPermSets][ERROR]', getErrorMessage(ex));
142-
tracker.error('[useProfilesAndPermSets][ERROR]', ex);
141+
logger.warn('[usePermissionRecords][ERROR]', getErrorMessage(ex));
142+
tracker.error('[usePermissionRecords][ERROR]', ex);
143143
if (isMounted.current) {
144144
setHasError(true);
145145
}

libs/features/manage-permissions/src/utils/permission-manager-table-utils.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@ export function getFieldColumns(
583583
permissionType: 'field',
584584
id: profileId,
585585
type: 'Profile',
586-
label: profile.Profile.Name,
586+
label: profile?.Profile?.Name || '',
587587
actionType: startCase(permissionType) as 'Read' | 'Edit',
588588
actionKey: permissionType,
589589
}),
@@ -986,7 +986,7 @@ export function getTabVisibilityColumns(
986986
permissionType: 'tabVisibility',
987987
id: profileId,
988988
type: 'Profile',
989-
label: profile.Profile.Name,
989+
label: profile?.Profile?.Name || '',
990990
actionType: startCase(actionKey) as 'Available' | 'Visible',
991991
actionKey,
992992
}),

libs/features/salesforce-api/src/SalesforceApi.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const SalesforceApi: FunctionComponent<SalesforceApiProps> = () => {
6363
})
6464
.catch((ex) => {
6565
logger.warn('[ERROR] Could not save history', ex);
66-
tracker.error('Error saving apex history', ex);
66+
tracker.error('Error saving API request history', ex);
6767
});
6868
} catch (ex) {
6969
setResults({

0 commit comments

Comments
 (0)