Skip to content

feat(destinations): Meta Conversions API destination (#1054) - #1073

Merged
masukai merged 10 commits into
mainfrom
feat/1054-meta-conversions
Sep 1, 2026
Merged

feat(destinations): Meta Conversions API destination (#1054)#1073
masukai merged 10 commits into
mainfrom
feat/1054-meta-conversions

Conversation

@masukai

@masukai masukai commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #1054. drt's first ad-platform destination beyond Google Ads — sends warehouse-computed conversion events to Meta's Conversions API (POST /{pixel_id}/events), batched up to Meta's documented 1000-events-per-request limit.

What changed

  • New MetaConversionsDestination (drt/destinations/meta_conversions.py) + MetaConversionsDestinationConfig.
  • Request shape and hashing rules verified directly against Meta's own developer docs (not guessed):
    • Auth: static access token as a query parameter (?access_token=...), not OAuth2 — simpler than Google Ads.
    • user_data.em/ph are SHA-256 hashes of normalized values (email: lowercase + trim; phone: digits-only). Hash output independently cross-checked against precomputed SHA-256 test vectors — confirmed byte-for-byte correct.
    • client_ip_address/client_user_agent/fbc/fbp deliberately stay plain text — Meta's docs call out hashing these as a common mistake.
    • api_version defaults to v25.0 (the field-tested prior release, not the just-shipped v26.0), fully configurable since Meta versions sunset on a ~2-year cycle.
  • Batches up to 1000 records per request; a partial-ack response (events_received != len(batch)) is conservatively treated as a full-batch failure, since Meta's synchronous response has no per-event error array to attribute individual failures against.
  • Full wiring: config union/discriminator, CLI connector listing, docs (new docs/connectors/meta-conversions.md), README/comparison.md connector counts (34→35), VS Code bundled schema (regenerated from a clean venv, verified byte-identical against an independent second clean-venv regen — avoiding the chore(ci): vscode-schema-drift check depends on an unpinned pydantic version, can fail PRs that never touched schemas #1070 stale-pydantic trap).

Verification

  • ruff check drt tests / mypy drt — clean
  • pytest tests/unit/test_meta_conversions_destination.py tests/unit/test_config.py tests/unit/test_destination_contract.py — 239 passed
  • pytest tests/unit — full suite green except the known-unrelated scratchpad-path CLI-version test artifact
  • Hash vectors independently verified: sha256("test@example.com") and sha256("14155551234") reproduced exactly, including from unnormalized inputs (mixed-case/whitespace email, formatted phone)

🤖 Generated with Claude Code

drt's first ad-platform destination beyond Google Ads. Sends
warehouse-computed conversion events to POST /{pixel_id}/events,
batched up to 1000 per request per Meta's documented limit.

Request shape and hashing verified directly against Meta's own API
docs: email/phone are SHA-256-hashed after normalization (lowercase +
trim for email, digits-only for phone) per Meta's customer-information
parameters spec; client_ip_address/client_user_agent/fbc/fbp stay
plain text, a documented easy-to-get-wrong distinction. Hash output
independently verified against precomputed SHA-256 test vectors.

Meta's synchronous response reports an aggregate events_received
count with no per-event error array, so a partial-ack batch is
conservatively treated as fully failed rather than guessing which
records succeeded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.45763% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
drt/destinations/meta_conversions.py 96.70% 6 Missing ⚠️

📢 Thoughts on this report? Let us know!

masukai and others added 7 commits September 1, 2026 14:19
…destination

- Suppress httpx's own INFO-level request logging in --log-format json
  (logging.getLogger("httpx").setLevel(WARNING)) — verified empirically
  that httpx logs full request URLs including query strings at INFO,
  and this connector's access_token is a query param, so every request
  was leaking the long-lived token into JSON log output. The default
  (non-JSON) text logging path doesn't raise root to INFO, so it was
  never affected. No other destination in this repo passes credentials
  via URL, confirming this was new to this connector, not a pre-existing
  repo-wide gap.
- Require event_id_field — without a stable id, with_retry's default
  3-attempt retry can resubmit a batch Meta already accepted after an
  ambiguous timeout, with no way for Meta to deduplicate it.
- Fail closed on a malformed acknowledgement: a missing, null, wrong-type,
  or boolean events_received previously fell through to recording success
  for up to 1000 unconfirmed events; now treated as a batch failure like
  an explicit count mismatch already was.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nt-age window

- Reject blank per-row event_id instead of silently sending an unkeyed
  event (same class of gap as the Klaviyo companion PR #1052).
- Meta's API requires at least one customer-information identifier in
  user_data (verified against Meta's own docs); require at least one
  identifier field configured, and reject a row where none resolve to
  a value.
- Reject event_time values older than Meta's documented 7-day
  acceptance window before sending, rather than letting the API reject
  it with less specific attribution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ite-event fields

- Move the httpx INFO-logging suppression to module import time in
  meta_conversions.py itself, so it applies regardless of entry point
  (CLI, dagster-drt, Airflow, MCP, direct library use) rather than only
  when drt's own --log-format json CLI path runs. Verified no
  Authorization-header alternative is documented for Meta's Conversions
  or Graph API before ruling that fix out.
- Require event_source_url_field and client_user_agent_field (config-
  and row-level) whenever action_source is "website" (the default) —
  Meta's own docs state website events without these "may be
  discarded." Scoped specifically to action_source == "website"; other
  action_source values aren't required to set these, since their own
  parameter requirements weren't verified here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-4 adversarial review fixes for #1054:
- custom_data.value is validated with math.isfinite() per row before
  batching, so one non-finite value fails only that row instead of
  the whole batch (httpx's JSON encoder previously rejected the whole
  request, failing up to 999 otherwise-valid events).
- Meta 400 responses with error.is_transient: true now retry via
  with_retry's retry_on predicate; other 400s still fail fast. Made
  retry_on actually apply on the httpx.HTTPStatusError path in the
  shared with_retry helper (previously only consulted there indirectly
  via retryable_status_codes) — backward compatible, verified against
  every other with_retry call site (161 tests).
- Documented the accepted residual risk of the import-time httpx
  logging suppression (defeatable by a caller reconfiguring logging
  after import) rather than adding a logging.Filter with the same
  defeat mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… safety

Round-5 adversarial review fixes for #1054:
- access_token now sent via Authorization: Bearer header instead of a
  URL query param. Verified directly against the real Meta Graph API
  (with an invalid test token) that both a POST body field and a
  Bearer header are accepted identically to the query param (all
  three return the same "cannot parse token" OAuthException, vs. a
  distinctly different error with no token at all).
- Removed the connector-level httpx logging suppression and its
  residual-risk documentation — no longer needed since the token
  never appears in a logged URL.
- Extended the per-row JSON-safety check from custom_data.value only
  to the full assembled event dict (json.dumps(..., allow_nan=False)),
  so a non-serializable value in any field (event_id, URLs, user
  agent, IP, fbc, fbp) fails only that row instead of poisoning the
  whole batch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-6 adversarial review fixes for #1054:
- event_id is now coerced to str for scalar (str/int/float, not bool)
  values, matching Meta's schema requirement that it be a string;
  non-scalar values are rejected as a row error. A raw numeric
  event_id previously passed the JSON-safety check (an int is valid
  JSON) but could cause Meta to reject the whole HTTP batch.
- Email/phone hashing now validates input type before hashing instead
  of blindly stringifying: email requires str; phone requires str or
  int, rejecting float outright (a float phone number's formatting
  isn't reliably recoverable). Previously e.g. float('nan') silently
  hashed to the SHA-256 of the literal string "nan" — a well-formed,
  Meta-accepted, but meaningless identifier with no error anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tests/contracts/test_destination_provider_uri_empty_batch.py and
test_destination_api_empty_batch.py construct MetaConversionsDestinationConfig
directly and were never updated when an earlier round of this PR added
a validator requiring event_source_url_field/client_user_agent_field
for the default action_source: "website". Both files failed at
collection time (pydantic ValidationError), breaking real GitHub
Actions CI (test 3.10-3.13) on every push since — masked locally
because this sandbox's own test runs report all failures under a
generic "sandbox denies localhost socket binding" bucket without
distinguishing a collection error from an actual socket-bound test.

Three sibling fixture files (test_describe.py,
test_credential_resolution_call_sites.py, test_docs_safe_labels.py)
already had the correct fields — only these two contract files were
missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-7 adversarial review fix for #1054: the previous round's
event_id coercion accepted any scalar float, including NaN/inf/-inf,
which stringify to "nan"/"inf"/"-inf". NaN is a common missing-value
sentinel in tabular data, so distinct rows with a missing numeric id
would silently collapse onto the same event_id — Meta deduplicates
matching event name + event_id pairs, discarding all but one, with no
error surfaced anywhere.

event_time's separate "silently defaults to sync time" finding from
this same review round is a design/default-behavior question, not a
narrow bug — filed as #1077 rather than fixed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@masukai

masukai commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Final adversarial review round flagged the same watermark-on-partial-failure pattern already tracked as engine-level in #1074 (found during #1072's review, applies identically to this connector and to every per-record destination using on_error: fail). Not connector-specific — tracked there, not fixed in this PR.

…sions

# Conflicts:
#	CHANGELOG.md
#	integrations/vscode-drt/CHANGELOG.md
@masukai
masukai merged commit 29704cb into main Sep 1, 2026
10 checks passed
@masukai
masukai deleted the feat/1054-meta-conversions branch September 1, 2026 12:18
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(destinations): no ad-platform conversion API destination beyond Google Ads (Meta/TikTok/LinkedIn)

1 participant