Skip to content

Facebook custom audiences - Refactor#3598

Open
joe-ayoub-segment wants to merge 32 commits intomainfrom
facebook-custom-audiences
Open

Facebook custom audiences - Refactor#3598
joe-ayoub-segment wants to merge 32 commits intomainfrom
facebook-custom-audiences

Conversation

@joe-ayoub-segment
Copy link
Contributor

@joe-ayoub-segment joe-ayoub-segment commented Feb 12, 2026

Refactoring Facebook Custom Audiences to tidy up code.
The refactor is limited because there are customers using this integration (which is in Public Beta).
This PR is designed to be non-breaking.

Testing

Unit tests updated.
Tested in Staging. Needs further staging testing.

Security Review

Please ensure sensitive data is properly protected in your integration.

  • Reviewed all field definitions for sensitive data (API keys, tokens, passwords, client secrets) and confirmed they use type: 'password'

New Destination Checklist

  • Extracted all action API versions to verioning-info.ts file. example

@codecov
Copy link

codecov bot commented Feb 26, 2026

Codecov Report

❌ Patch coverage is 72.24335% with 73 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.35%. Comparing base (0577e4a) to head (83af38b).

Files with missing lines Patch % Lines
...s/facebook-custom-audiences/sync/hook-functions.ts 14.28% 30 Missing ⚠️
...ations/facebook-custom-audiences/sync/functions.ts 83.44% 7 Missing and 17 partials ⚠️
...estinations/facebook-custom-audiences/functions.ts 75.55% 6 Missing and 5 partials ⚠️
...rc/destinations/facebook-custom-audiences/index.ts 70.58% 2 Missing and 3 partials ⚠️
...stinations/facebook-custom-audiences/sync/index.ts 66.66% 3 Missing ⚠️

❌ Your patch check has failed because the patch coverage (72.24%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3598      +/-   ##
==========================================
- Coverage   80.35%   80.35%   -0.01%     
==========================================
  Files        1309     1314       +5     
  Lines       23946    24007      +61     
  Branches     4829     4868      +39     
==========================================
+ Hits        19243    19291      +48     
+ Misses       3807     3801       -6     
- Partials      896      915      +19     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Contributor

@varadarajan-tw varadarajan-tw left a comment

Choose a reason for hiding this comment

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

Gave a first pass and made few suggestions. I'd like to review this again after stage testing.

label: 'Sync Mode',
description: 'The sync mode to use when syncing data to Facebook.',
default: 'upsert',
description: 'Define how the records will be synced to Facebook Custom Audiences. When syncing Engage Audiences this should be set to "Mirror". When syncing from Reverse ETL, choose Upsert or Delete.',
Copy link
Contributor

Choose a reason for hiding this comment

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

"Mirror" could work with RETL too, right?

const addAccountId = (engageAdAccountId ?? retlAdAccountId) as string
const { data: { externalId: id } = {}, error } = await createAudience(request, audienceName, addAccountId, audienceDescription)
if (error) {
throw new IntegrationError(error.message || 'Failed to create audience', error.code || 'CREATE_AUDIENCE_FAILED', 400)
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we add this CREATE_AUDIENCE_FIELD to error codes?

const { externalId } = getAudienceInput
const { data: { externalId: id } = {}, error } = await getAudience(request, externalId)
if (error) {
throw new IntegrationError(error.message || 'Failed to get audience', error.code || 'GET_AUDIENCE_FAILED', 400)
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we add GET_AUDIENCE_FAILED to error codes?


try {
const response = await request<GetAudienceResponse>(url, { method: 'GET' })
const r = await response.json()
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: response.data already would have parsed value. I would suggest changing it to response.data

json
})

const r = await response.json()
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: response.data already would have parsed value. I would suggest changing it to response.data

Comment on lines 1 to -54
export const API_VERSION = 'v24.0'
export const CANARY_API_VERSION = 'v24.0'
Copy link
Contributor

Choose a reason for hiding this comment

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

We should retain this setup to switch between CANARY to STABLE API Version. Otherwise, the engineer has to reintroduce code and write test cases every 6 months when facebook deprecates its APIs.

Comment on lines +47 to +52
requests.push(sendRequest(request, audienceId, addMap, msResponse, 'POST', isBatch))
}

if (deleteMap.size > 0) {
requests.push(sendRequest(request, audienceId, deleteMap, msResponse, 'DELETE', isBatch))
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't have any specific opinion but wondering if we should use batch keys and ensure all Deletes and Adds are batched together? This way we don't have to retry two API calls

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think we can do this as the name of the audience is variable - so what would the batck key value be?

mobileAdId
} = payload

const row: FacebookDataRow = [
Copy link
Contributor

Choose a reason for hiding this comment

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

Claude review pointed out the properties are out of order

Copy link
Contributor

Choose a reason for hiding this comment

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

  1. SCHEMA_PROPERTIES Order Changed — Data Corruption Risk

sync/constants.ts:23-38

The order of SCHEMA_PROPERTIES has been changed compared to the original in fbca-properties.ts. Facebook's API expects data columns in the exact order specified by the schema array. The reordering moves:

  • GEN (gender) from index 3 → index 9
  • DOBY/DOBM/DOBD shift up to fill the gap
  • COUNTRY and MADID swap positions

This means gender data will be sent as year-of-birth, and vice versa, causing silent data corruption for all existing customers. This is the highest priority fix needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Claude got it wrong. I reorded the values to be in line with facebook's doc (so they would be easier to audit in future.

The order is controlled using this:

export const SCHEMA_PROPERTIES = [
  'EXTERN_ID',
  'EMAIL',
  'PHONE',
  'DOBY',
  'DOBM',
  'DOBD',
  'LN',
  'FN',
  'FI',
  'GEN',
  'CT',
  'ST',
  'ZIP',
  'COUNTRY',
  'MADID'
] as const 

I just changed the order accordingly.

I'm OK to set this back to the original ordering if it's safer...

@varadarajan-tw
Copy link
Contributor

varadarajan-tw commented Feb 27, 2026

Sharing comments from deep review using Claude. It pointed out 1 critical issue.

PR #3598 Review: Facebook Custom Audiences — Refactor

Author: Joe Ayoub | Branch: facebook-custom-audiencesmain
Files changed: 25 | +3,284 / -1,666


Critical

1. SCHEMA_PROPERTIES Order Changed — Data Corruption Risk

sync/constants.ts:23-38

The order of SCHEMA_PROPERTIES has changed compared to the original in fbca-properties.ts. Facebook's API interprets the data array positionally against the schema array. The reordering moves:

  • GEN (gender) from index 3 to index 9
  • DOBY/DOBM/DOBD shift up to fill the gap
  • COUNTRY and MADID swap positions
Field Old Index New Index
GEN 3 9
DOBY 4 3
DOBM 5 4
DOBD 6 5
LN 7 6
FN 8 7
FI 9 8
COUNTRY 14 13
MADID 13 14

Gender data will be sent as year-of-birth and vice versa, causing silent data corruption for all existing customers. This is a showstopper.

Fix: Revert SCHEMA_PROPERTIES to the original order.


2. Feature Flag & Canary API Version Removed

constants.ts

The original code had FACEBOOK_CUSTOM_AUDIENCE_FLAGON, CANARY_API_VERSION, and a getApiVersion() function that selected the API version based on feature flags and tracked stats via statsClient.incr(). All of this is removed — the destination now hardcodes v24.0.

Per CLAUDE.md: "For high-volume destinations, use feature flags for safe rollout."

Fix: Preserve feature flag support or confirm no customers are on the canary path before removing.


3. error_user_message Fallback Lost

functions.ts:11-30

The original error parser checked for both error_user_msg and error_user_message from Facebook's API response. The new parseFacebookError() only checks error_user_msg, losing the fallback.

- userMsg = parsed.error.error_user_msg || parsed.error.error_user_message
+ error_user_msg  // no error_user_message fallback

Fix: Add error_user_message as a fallback in parseFacebookError().


High

4. EmptyValueError Handling Removed

sync/functions.ts:207-219

The original fbca-operations.ts wrapped processHashing() calls in try-catch to convert EmptyValueError into a user-friendly PayloadValidationError:

// Original
throw new PayloadValidationError(
  `Invalid value for ${key}. After normalization, the value is empty.`
)

The new getData() calls processHashing() without this handling. Errors will bubble up with unhelpful messages.

Fix: Wrap processHashing() calls in try-catch or create a shared helper that converts EmptyValueError to PayloadValidationError.


5. Missing Integration Test Files

The PR deletes 809 lines of tests from sync/__tests__/index.test.ts, __tests__/index.test.ts, and __tests__/fb-operations.test.ts. The following integration test scenarios from deleted files have no replacement:

Deleted Test Scenario Covered in New Tests?
Mirror mode (add + delete split based on audience membership) No
Upsert end-to-end flow No
RETL single user sync with canary API version No
Flagged audience error subcode 1713231 handling No
Pre-hashed email preservation (no double-hashing) No
Engage audience sync with external_id No
Facebook API error for flagged Engage audiences No

6. No Tests for retlOnMappingSave Hook

sync/hook-functions.ts

The performHook() function orchestrates audience creation/lookup and is the customer-facing entry point for mapping configuration. It has zero test coverage. Missing scenarios:

  • operation: 'create' with valid audienceName
  • operation: 'existing' with valid existingAudienceId
  • Invalid operation handling
  • Error propagation from createAudience() / getAudience()

7. No Tests for sendRequest()

sync/functions.ts

This function handles all Facebook API communication — error parsing, batch vs non-batch mode, multi-status responses. No unit tests exist. Missing scenarios:

  • Success response handling
  • Facebook error response parsing
  • Batch mode error accumulation vs non-batch throwing
  • Partial batch failures

Medium

8. Duplicated Ad Account ID Formatting

functions.ts:43-45, hook-functions.ts:89-91

The adAccountId.startsWith('act_') ? adAccountId.slice(4) : adAccountId logic is duplicated. The original code had a single formatAdAccount() method.

Fix: Extract to a shared utility.


9. No Batch Size Edge Case Tests

sync/__tests__/delete.test.ts

Only tests 3 users per batch. Missing edge cases:

  • Maximum batch size (10,000)
  • Empty batch
  • Single item batch
  • Batching disabled

10. API Version Jump Undocumented

constants.ts

The API version jumps from v19.0/v21.0 to v24.0 with no migration notes in the PR description. This is a significant version bump that could introduce breaking changes from Facebook's side.


11. Instagram Account IDs — New Field Needs Verification

sync/fields.ts:123-127

A new igAccountIds field is added. While it appears optional/additive, verify that:

  • Existing mappings without this field won't break
  • Facebook's API accepts ig_account_ids alongside existing fields
  • Generated types include this as optional

Nit

12. Use Labeled Tuple Elements for FacebookDataRow

sync/types.ts

Since this project uses TypeScript 5.5, consider labeled tuple elements for self-documenting positional types:

export type FacebookDataRow = [
  externId: string,
  email: string,
  phone: string,
  year: string,
  month: string,
  day: string,
  lastName: string,
  firstName: string,
  firstInitial: string,
  gender: string,
  city: string,
  state: string,
  zip: string,
  country: string,
  mobileAdId: string,
]

Labels don't affect runtime behavior but make it easier to catch ordering mistakes during code review and improve IDE hover tooltips.


Positive Aspects

  • Code organization is significantly improved — functions, types, constants, and fields cleanly separated into focused modules
  • 62 unit tests for normalization functions in functions.test.ts are thorough
  • Audience creation tests are well-structured with good error case coverage
  • Net test line count is slightly positive (+15 lines)
  • The decomposition of the monolithic sync/index.ts is a good architectural decision

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants