Skip to content

Commit d4d9801

Browse files
feat(fb_custom_audience): handle invalid fields and events (#5023)
## Summary - Validate email fields using `validator.isEmail`; emit `fb_custom_audience_invalid_email` stats metric for invalid emails instead of silently passing them through - Validate country codes must be exactly 2 characters; emit `fb_custom_audience_invalid_country_code` stats metric for invalid codes - Throw `InstrumentationError` when all user properties for a record are null/invalid, replacing the previous silent stats counter (`fb_custom_audience_event_having_all_null_field_values_for_a_user`) - Pass `workspaceId` and `destinationId` through the call chain to enable proper metric attribution 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent dfc0f3b commit d4d9801

File tree

5 files changed

+99
-122
lines changed

5 files changed

+99
-122
lines changed

src/v0/destinations/fb_custom_audience/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,17 @@ function getMaxPayloadSize(workspaceId: string): number {
126126
return DEFAULT_MAX_PAYLOAD_SIZE;
127127
}
128128

129+
/**
130+
* Whether to reject invalid field values (e.g., malformed emails, invalid country codes)
131+
* by replacing them with empty strings. When disabled, invalid values are passed through as-is.
132+
*
133+
* Controlled via env var: FB_CUSTOM_AUDIENCE_REJECT_INVALID_FIELDS=true
134+
* Default: false
135+
*/
136+
function isRejectInvalidFieldsEnabled(): boolean {
137+
return process.env.FB_CUSTOM_AUDIENCE_REJECT_INVALID_FIELDS === 'true';
138+
}
139+
129140
export {
130141
DESTINATION,
131142
ENDPOINT_PATH,
@@ -137,4 +148,5 @@ export {
137148
typeFields,
138149
subTypeFields,
139150
getMaxPayloadSize,
151+
isRejectInvalidFieldsEnabled,
140152
};

src/v0/destinations/fb_custom_audience/recordTransform.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import type {
1717
FbRecordEvent,
1818
} from './types';
1919
import { schemaFields, MAX_USER_COUNT } from './config';
20-
import stats from '../../../util/stats';
2120
import {
2221
getDestinationExternalIDInfoForRetl,
2322
checkSubsetOfArray,
@@ -51,6 +50,8 @@ const processRecord = (
5150
userSchema: string[],
5251
isHashRequired: boolean,
5352
disableFormat: boolean | undefined,
53+
workspaceId: string,
54+
destinationId: string,
5455
): { dataElement: unknown[]; metadata: Metadata } => {
5556
const fields = record.message.fields!;
5657
let dataElement: unknown[] = [];
@@ -61,7 +62,12 @@ const processRecord = (
6162
let updatedProperty: unknown = userProperty;
6263

6364
if (isHashRequired && !disableFormat) {
64-
updatedProperty = ensureApplicableFormat(eachProperty, userProperty);
65+
updatedProperty = ensureApplicableFormat(
66+
eachProperty,
67+
userProperty,
68+
workspaceId,
69+
destinationId,
70+
);
6571
}
6672

6773
dataElement = getUpdatedDataElement(
@@ -79,10 +85,9 @@ const processRecord = (
7985
});
8086

8187
if (nullUserData) {
82-
stats.increment('fb_custom_audience_event_having_all_null_field_values_for_a_user', {
83-
destinationId: record.destination.ID,
84-
nullFields: userSchema,
85-
});
88+
throw new InstrumentationError(
89+
`All user properties [${userSchema.join(', ')}] are invalid or null. At least one valid field is required.`,
90+
);
8691
}
8792

8893
return { dataElement, metadata: record.metadata };
@@ -115,6 +120,8 @@ const processRecordEventArray = async (
115120
userSchema,
116121
isHashRequired,
117122
disableFormat,
123+
input.metadata.workspaceId,
124+
destination.ID,
118125
);
119126
metadata.push(recordMetadata);
120127
return dataElement;

src/v0/destinations/fb_custom_audience/util.ts

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import lodash from 'lodash';
22
import sha256 from 'sha256';
33
import crypto from 'crypto';
44
import jsonSize from 'json-size';
5+
import validator from 'validator';
56
import {
67
InstrumentationError,
78
ConfigurationError,
@@ -15,7 +16,13 @@ import type {
1516
FbRecordMessage,
1617
WrappedResponse,
1718
} from './types';
18-
import { typeFields, subTypeFields, getEndPoint, DESTINATION } from './config';
19+
import {
20+
typeFields,
21+
subTypeFields,
22+
getEndPoint,
23+
isRejectInvalidFieldsEnabled,
24+
DESTINATION,
25+
} from './config';
1926
import {
2027
defaultRequestConfig,
2128
defaultPostRequestConfig,
@@ -77,20 +84,37 @@ const getSchemaForEventMappedToDest = (message: FbRecordMessage): string[] => {
7784
return userSchema;
7885
};
7986

80-
// function responsible to ensure the user inputs are passed according to the allowed format
81-
const ensureApplicableFormat = (userProperty: string, userInformation: unknown): unknown => {
87+
/**
88+
* Ensures user inputs are in the format required by Facebook Custom Audiences.
89+
* Returns empty string for invalid field values.
90+
*/
91+
const ensureApplicableFormat = (
92+
userProperty: string,
93+
userInformation: unknown,
94+
workspaceId: string,
95+
destinationId: string,
96+
): unknown => {
8297
let updatedProperty: unknown;
8398
let userInformationTrimmed: string;
8499
if (isDefinedAndNotNull(userInformation)) {
85100
const stringifiedUserInformation = convertToString(userInformation);
86101
switch (userProperty) {
87-
case 'EMAIL':
88-
updatedProperty = stringifiedUserInformation.trim().toLowerCase();
102+
case 'EMAIL': {
103+
const emailValue = stringifiedUserInformation.trim().toLowerCase();
104+
if (validator.isEmail(emailValue)) {
105+
updatedProperty = emailValue;
106+
} else {
107+
stats.increment('fb_custom_audience_invalid_email', { workspaceId, destinationId });
108+
updatedProperty = isRejectInvalidFieldsEnabled() ? '' : emailValue;
109+
}
89110
break;
90-
case 'PHONE':
111+
}
112+
case 'PHONE': {
91113
// remove all non-numerical characters, then remove all leading zeros
92114
updatedProperty = stringifiedUserInformation.replace(/\D/g, '').replace(/^0+/g, '');
115+
// Note: libphonenumber-js is not used here as it requires a country code to validate, which may not always be present.
93116
break;
117+
}
94118
case 'GEN':
95119
updatedProperty =
96120
stringifiedUserInformation.toLowerCase() === 'f' ||
@@ -124,9 +148,19 @@ const ensureApplicableFormat = (userProperty: string, userInformation: unknown):
124148
case 'MADID':
125149
updatedProperty = stringifiedUserInformation.toLowerCase();
126150
break;
127-
case 'COUNTRY':
128-
updatedProperty = stringifiedUserInformation.toLowerCase();
151+
case 'COUNTRY': {
152+
const countryCode = stringifiedUserInformation.toLowerCase();
153+
if (countryCode.length === 2) {
154+
updatedProperty = countryCode;
155+
} else {
156+
stats.increment('fb_custom_audience_invalid_country_code', {
157+
workspaceId,
158+
destinationId,
159+
});
160+
updatedProperty = isRejectInvalidFieldsEnabled() ? '' : countryCode;
161+
}
129162
break;
163+
}
130164
case 'ZIP':
131165
userInformationTrimmed = stringifiedUserInformation.replace(/\s/g, '');
132166
updatedProperty = userInformationTrimmed.toLowerCase();
@@ -224,7 +258,12 @@ const prepareDataField = (
224258
let updatedProperty: unknown = userProperty;
225259

226260
if (isHashRequired && !disableFormat) {
227-
updatedProperty = ensureApplicableFormat(eachProperty, userProperty);
261+
updatedProperty = ensureApplicableFormat(
262+
eachProperty,
263+
userProperty,
264+
workspaceId,
265+
destinationId,
266+
);
228267
}
229268

230269
dataElement = getUpdatedDataElement(
@@ -243,10 +282,9 @@ const prepareDataField = (
243282
});
244283

245284
if (nullUserData) {
246-
stats.increment('fb_custom_audience_event_having_all_null_field_values_for_a_user', {
247-
destinationId,
248-
nullFields: userSchema,
249-
});
285+
throw new InstrumentationError(
286+
`All user properties [${userSchema.join(', ')}] are invalid or null. At least one valid field is required.`,
287+
);
250288
}
251289

252290
data.push(dataElement);

test/integrations/destinations/fb_custom_audience/processor/data.ts

Lines changed: 10 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2029,47 +2029,17 @@ export const data = [
20292029
status: 200,
20302030
body: [
20312031
{
2032-
output: {
2033-
version: '1',
2034-
type: 'REST',
2035-
method: 'POST',
2036-
endpoint: 'https://graph.facebook.com/v23.0/aud1/users',
2037-
endpointPath: 'users',
2038-
headers: {},
2039-
userId: '',
2040-
params: {
2041-
access_token: 'ABC',
2042-
payload: {
2043-
is_raw: true,
2044-
data_source: {
2045-
type: 'UNKNOWN',
2046-
sub_type: 'ANYTHING',
2047-
},
2048-
schema: [
2049-
'EMAIL',
2050-
'DOBM',
2051-
'DOBD',
2052-
'DOBY',
2053-
'PHONE',
2054-
'GEN',
2055-
'FI',
2056-
'MADID',
2057-
'ZIP',
2058-
'ST',
2059-
'COUNTRY',
2060-
],
2061-
data: [['', '', '', '', '', '', '', '', '', '', '']],
2062-
},
2063-
},
2064-
body: {
2065-
JSON: {},
2066-
JSON_ARRAY: {},
2067-
XML: {},
2068-
FORM: {},
2069-
},
2070-
files: {},
2032+
error:
2033+
'All user properties [EMAIL, DOBM, DOBD, DOBY, PHONE, GEN, FI, MADID, ZIP, ST, COUNTRY] are invalid or null. At least one valid field is required.',
2034+
statTags: {
2035+
destType: 'FB_CUSTOM_AUDIENCE',
2036+
errorCategory: 'dataValidation',
2037+
errorType: 'instrumentation',
2038+
feature: 'processor',
2039+
implementation: 'native',
2040+
module: 'destination',
20712041
},
2072-
statusCode: 200,
2042+
statusCode: 400,
20732043
},
20742044
],
20752045
},

test/integrations/destinations/fb_custom_audience/router/data.ts

Lines changed: 13 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -884,44 +884,9 @@ export const data = [
884884
body: {
885885
output: [
886886
{
887-
batchedRequest: [
888-
{
889-
version: '1',
890-
type: 'REST',
891-
method: 'POST',
892-
endpoint: 'https://graph.facebook.com/v23.0/23848494844100489/users',
893-
endpointPath: 'users',
894-
headers: {},
895-
params: {
896-
access_token: 'ABC',
897-
payload: {
898-
schema: ['EMAIL', 'FI'],
899-
data: [
900-
[
901-
'b100c2ec0718fe6b4805b623aeec6710719d042ceea55f5c8135b010ec1c7b36',
902-
'1e14a2f476f7611a8b22bc85d14237fdc88aac828737e739416c32c5bce3bd16',
903-
],
904-
[
905-
'b100c2ec0718fe6b4805b623aeec6710719d042ceea55f5c8135b010ec1c7b36',
906-
'1e14a2f476f7611a8b22bc85d14237fdc88aac828737e739416c32c5bce3bd16',
907-
],
908-
[
909-
'b100c2ec0718fe6b4805b623aeec6710719d042ceea55f5c8135b010ec1c7b36',
910-
'1e14a2f476f7611a8b22bc85d14237fdc88aac828737e739416c32c5bce3bd16',
911-
],
912-
['', ''],
913-
],
914-
},
915-
},
916-
body: {
917-
JSON: {},
918-
JSON_ARRAY: {},
919-
XML: {},
920-
FORM: {},
921-
},
922-
files: {},
923-
},
924-
],
887+
batched: false,
888+
error:
889+
'All user properties [EMAIL, FI] are invalid or null. At least one valid field is required.',
925890
metadata: [
926891
{
927892
attemptNum: 1,
@@ -972,31 +937,16 @@ export const data = [
972937
workspaceId: 'default-workspaceId',
973938
},
974939
],
975-
batched: true,
976-
statusCode: 200,
977-
destination: {
978-
Config: {
979-
accessToken: 'ABC',
980-
disableFormat: false,
981-
isHashRequired: true,
982-
isRaw: false,
983-
skipVerify: false,
984-
subType: 'NA',
985-
type: 'NA',
986-
},
987-
Name: 'FB_CUSTOM_AUDIENCE',
988-
Enabled: true,
989-
WorkspaceID: '1TSN08muJTZwH8iCDmnnRt1pmLd',
990-
DestinationDefinition: {
991-
Config: {},
992-
DisplayName: 'FB_CUSTOM_AUDIENCE',
993-
ID: '1aIXqM806xAVm92nx07YwKbRrO9',
994-
Name: 'FB_CUSTOM_AUDIENCE',
995-
},
996-
ID: '1mMy5cqbtfuaKZv1IhVQKnBdVwe',
997-
Transformations: [],
998-
IsConnectionEnabled: true,
999-
IsProcessorEnabled: true,
940+
statusCode: 400,
941+
statTags: {
942+
errorCategory: 'dataValidation',
943+
errorType: 'instrumentation',
944+
destType: 'FB_CUSTOM_AUDIENCE',
945+
module: 'destination',
946+
implementation: 'native',
947+
feature: 'router',
948+
destinationId: 'default-destinationId',
949+
workspaceId: 'default-workspaceId',
1000950
},
1001951
},
1002952
],

0 commit comments

Comments
 (0)