Articles liés

La newsletter française des développeurs Ruby on Rails. Retrouve du contenu similaire gratuitement tous les mois dans ta boîte mail !
S'inscrire
Partager :
Blog
>

🍪 🔄 Synchronisez votre CRM depuis Rails avec etlify

Chez l'un de nos clients qui propose une plateforme d'investissements en ligne, l'équipe commerciale travaille depuis Airtable. C'est leur outil au quotidien pour suivre les utilisateurs, les investissements et les mouvements financiers. Sauf que la donnée source, elle, vit dans l'app Rails. Il faut donc pousser les données de Rails vers Airtable, et les garder à jour.

Au début, nous synchronisions un model. Puis deux. Puis cinq. Chaque model avait son propre worker, son serializer, sa transaction, ses specs. Six à huit fichiers à créer pour chaque nouveau model synchronisé. Vingt-quatre fichiers dédiés rien qu'à la sync. Et quand ça cassait, la synchronisation périodique repassait sans corriger le problème, et les erreurs polluaient Appsignal, notre outil de monitoring.

Le vrai problème n'était pas le code existant. C'était la question : "combien de temps pour ajouter un sixième model ?" Réponse : une demi-journée, à câbler les callbacks, copier-coller la logique de digest, prier pour ne rien oublier.


Nous avons d'abord cherché des gems existantes. La plupart ciblaient un CRM spécifique (HubSpot, Salesforce) ou imposaient un couplage fort avec ActiveRecord. Rien qui colle à notre besoin : un système déclaratif, agnostique du CRM, capable de gérer les dépendances entre models.

Nous avons décidé d'écrire notre propre gem. L'idée de départ : rendre la sync CRM aussi simple qu'un has_many ou un validates. Déclarer dans le model ce que nous synchronisons, comment nous le transformons, et laisser la gem gérer le reste.

Quelques mois plus tard, nous avons Etlify, une gem Rails open source créée par Capsens. Le nom vient de l'acronyme ETL : Extract, Transform, Load. C'est exactement ce que fait la gem : extraire les données d'ActiveRecord, les transformer via un serializer, et les charger dans le CRM.

Ce que nous y avons gagné

Avant d’entrer dans le code, voici les chiffres mesurés sur notre migration :

L’architecture en 30 secondes

Etlify repose sur quatre briques, qui suivent la logique ETL.

Extract : la détection des stale records scanne périodiquement vos models pour détecter les records dont le digest a changé sans appel explicite, et relance leur synchronisation.

Transform : le serializer (appelé dictionary dans la gem) transforme un record ActiveRecord en Hash CRM-compatible. Un par model synchronisé.

Load : le synchronizer orchestre le chargement. Il calcule une empreinte SHA256 du payload. Si rien n’a changé, il passe. Sinon il appelle l’adapter (la couche HTTP vers le CRM) et stocke le résultat dans crm_synchronisations.

crm_sync! → Worker (async) → Synchronizer
  ├── sync_if → false ?        → :skipped
  ├── dependency manquante ?   → PendingSync → :buffered
  ├── digest identique ?       → :not_modified
  └── Serializer#to_h → Adapter#upsert! → :synced

Le worker, l’adapter et le synchronizer sont partagés entre tous les models. Vous n’écrivez que ce qui est spécifique : le serializer et la config.

Implémentation

Installation

Ajoutez la gem à votre Gemfile, avec faraday (HTTP), sidekiq-throttled (rate limiting) et sidekiq-unique-jobs (dédup des jobs) si ce n’est pas déjà fait :

gem "etlify", git: "git@github.com:CapSens/etlify.git", tag: "v0.9.3"

Puis :

bundle install

Avant de lancer les migrations, créez l’initializer pour que la gem soit configurée :

Ensuite, générez et lancez les migrations :

rails g etlify:migration create_crm_synchronisations
rails g etlify:migration create_etlify_pending_syncs
rails db:migrate

crm_synchronisations stocke pour chaque record synchronisé :

etlify_pending_syncs garde les syncs bloquées par une dépendance (nous y reviendrons).

L’adapter

La gem fournit le contrat. L’adapter, c’est vous qui l’écrivez. Voici le nôtre pour Airtable :

upsert! retourne le crm_id. delete! retourne un booléen. Les méthodes CRUD privées sont du Faraday classique (POST pour créer, PATCH pour mettre à jour, GET avec filterByFormula pour chercher un record existant).

Point important : la gem ne déclenche pas delete! sur after_destroy. Si vous supprimez un record en base, le record côté CRM reste. À vous de décider où et quand appeler delete! explicitement.

Pour un autre CRM, implémentez upsert!, delete! et le mapping d’erreurs dans handle_response. Le reste change, la mécanique reste la même.

La config YAML

Chaque model a son fichier YAML. L’idée c’est de découpler vos noms de champs des IDs Airtable. Un champ renommé côté CRM ? Vous changez le YAML, pas le code.

Déclarer un model synchronisable

Deux choses à ajouter au model : include Etlify::Model pour le DSL, et has_many :crm_synchronisations pour l’association polymorphique.

Le YAML est chargé au boot via la constante CONFIG_PATH du serializer, un seul endroit où le chemin est défini. crm_object_type reçoit le table ID. id_property sert à retrouver un record existant côté CRM.

Quatre options dans le DSL méritent que nous nous y arrêtions. Nous avons mis un moment à bien les cerner.

sync_dependencies: [:customer] est bloquant. Si le Customer n’a pas de crm_id, la sync est mise en attente. Un PendingSync est créé. Etlify déclenche la sync du Customer en cascade. Quand celui-ci sera sync, les syncs en attente seront exécutées.

dependencies: [:products] est non bloquant. Le serializer de l’Order inclut des données des Products (ex : leur nom, leur prix). Si un Product change, le digest SHA256 de l’Order change aussi au prochain calcul. Le cron de stale records détecte cette différence et re-sync l’Order automatiquement.

sync_if filtre les records éligibles. Si le lambda retourne false, le synchronizer retourne :skipped et ne touche pas au CRM. Attention : un record déjà synchronisé dont le sync_if retourne ensuite false ne sera ni re-sync ni supprimé côté CRM. Cela signifie que si un record change d’état (par exemple, il redevient non éligible), il restera tel quel côté CRM. Pour le retirer, appelez delete! explicitement. À utiliser avec discernement, sur des états réellement définitifs.

stale_scope restreint le scan du cron aux records concernés.

La cascade fonctionne en profondeur. Imaginez : Order → Customer → Company. Etlify met en attente et remonte la chaîne jusqu’à trouver un crm_id. Si ça vous rappelle les poupées russes, c’est normal.

Le serializer

Le serializer, c’est le fichier où vous décidez ce que le CRM voit de vos données. D’abord, la classe de base :

Le helpers/h donne accès aux helpers Rails (h.number_to_currency, h.truncate, etc.) directement dans vos serializers. Ça dépanne plus souvent qu’on ne le pense.

Puis le serializer du model :

Les clés du Hash retourné par to_h sont les field IDs Airtable, pas vos noms de colonnes. Un serializer par model synchronisé.

Le worker

Le model sait quoi, le serializer sait comment. Reste le transport. La gem fournit un Etlify::SyncJob par défaut. Ici nous le remplaçons par un worker Sidekiq avec throttling pour respecter les limites Airtable :

Le throttle :airtable_api doit être déclaré dans votre initializer Sidekiq :

Et la queue crm dans votre config :

Le worker a retry: false côté Sidekiq. Le synchronizer fait un seul essai par invocation : s’il échoue, il incrémente error_count dans crm_synchronisations et passe au suivant. Pas de retry en boucle, c’est le cron des stale records qui rattrape le coup au cycle suivant. Après 3 échecs consécutifs (configurable via max_sync_errors), le record est exclu du cron automatique. Il reste synchronisable manuellement via crm_sync!. Un appel réussi remet error_count à zéro.

En complément, un worker cron rattrape les records dont le digest a changé sans appel explicite :

Planifiez-le dans votre config/schedule.yml à la fréquence qui vous convient.

Déclencher la sync

Tout est en place. Pour synchroniser un record : order.crm_sync!(crm_name: :airtable). Un one-liner depuis vos services, transactions ou controllers. Nous l’appelons après un paiement validé, dans nos transactions de membership, dans les services d’onboarding. Le synchronizer gère le reste.

Migrer depuis un système legacy

Si vos models avaient une colonne airtable_id, ajoutez un fallback :

Les deux systèmes cohabitent. Vous migrez model par model.

Pour éviter de re-sync tous vos records via l’API, un rake task crée les CrmSynchronisation en masse à partir des airtable_id existants :

Le task stocke l’empreinte actuelle. Seuls les records modifiés après le backfill seront re-sync. Pas de flood API.

Tests

Pour vos tests, mockez les appels HTTP vers Airtable plutôt que de remplacer l’adapter. Cela permet de tester le vrai comportement de bout en bout, y compris votre handle_response et le mapping d’erreurs :

Puis testez vos serializers et le comportement de sync :

Etlify n’est pas limitée à Airtable. L’architecture par adapter permet de brancher n’importe quel CRM. La gem embarque un NullAdapter pour vos tests et le développement local. Si vous utilisez ActiveAdmin, les tables crm_synchronisations et etlify_pending_syncs se branchent très bien pour monitorer vos syncs et relancer les records en erreur.

Si vous synchronisez un CRM depuis Rails et que ça commence à devenir pénible, essayez-la. Nous aurions aimé l’avoir plus tôt.

La gem est open source : github.com/CapSens/etlify

‍

— Benjamin, développeur chez Capsens