Chapitre 7.4 — Tracer hors du LMS
⏱️ TL;DR — C’est le super-pouvoir de xAPI, ce que SCORM ne saura jamais faire : tracer l’apprentissage partout — appli mobile native, atelier présentiel avec la tablette du formateur, simulateur métier, geste sur le terrain — et même hors-ligne. Deux problèmes à régler proprement. Un : l’identité de l’apprenant, puisqu’il n’y a pas de lancement LMS qui te la fournit — tu dois l’établir toi-même (compte applicatif, SSO). Deux : la connexion intermittente — tu captures dans une file d’attente locale, avec l’
idet letimestampfigés à l’événement, et tu synchronises enPUTidempotent dès que le réseau revient. Ce chapitre montre le connecteur hors-ligne et les patrons d’identité.
🎯 Objectifs
- Identifier les sources hors LMS où xAPI brille et SCORM échoue.
- Identifier l’acteur sans lancement LMS (compte applicatif, SSO,
accountvsmbox). - Concevoir une file d’attente hors-ligne avec synchro différée idempotente.
- Préserver le
timestampréel de l’événement malgré une synchro tardive. - Câbler plusieurs sources sur un seul LRS cohérent.
Là où SCORM s’arrête
Rappel de la limite structurelle de SCORM (Partie 2) : il ne tracke que dans un navigateur, lancé depuis un LMS. Sitôt qu’on sort de ce cadre, il n’a rien à proposer. Or l’apprentissage réel déborde largement l’écran du LMS :
| Source | Exemple concret | Pourquoi SCORM ne peut pas |
|---|---|---|
| Appli mobile native | Micro-learning dans les transports | Pas de navigateur LMS, souvent hors-ligne |
| Présentiel | Le formateur valide un geste sur sa tablette | Aucun contenu web lancé par un LMS |
| Simulateur | Un simulateur de conduite ou de soins | Application métier autonome |
| Terrain | Un apprenti valide une opération en atelier | Pas d’écran LMS du tout |
| Objet connecté / jeu | Un serious game, un capteur | Hors du modèle SCORM |
xAPI traite tout ça pareil : chaque expérience devient un statement acteur – verbe – objet envoyé au LRS. Le LRS ne sait pas (et se moque de savoir) si le statement vient d’un module web, d’une appli iOS ou d’une tablette de formateur. C’est ce découplage qui débloque le hors-LMS.
Problème n°1 : qui est l’apprenant ?
Dans un module SCORM ou cmi5, le LMS te donne l’apprenant (au lancement, via cmi.core.student_id en SCORM, le paramètre actor en cmi5). Hors LMS, personne ne te le donne : c’est à toi d’identifier l’acteur de façon stable et cohérente.
Rappel (Partie 6) : un acteur xAPI s’identifie par mbox, mbox_sha1sum, account (homePage + name) ou openid. Le choix n’est pas anodin.
| Identifiant | Forme | Quand l’utiliser |
|---|---|---|
mbox | mailto:awa.diop@ecole.fr | Si tu as un e-mail fiable et stable |
mbox_sha1sum | empreinte SHA1 du mailto: | Même chose, mais e-mail non exposé en clair |
account | homePage + name (id interne) | Le plus robuste hors LMS : ton id utilisateur applicatif |
openid | une URL OpenID | Si tu fédères l’identité via OpenID |
Pour une appli mobile FormaCampus, le bon choix est presque toujours account : l’apprenant se connecte à l’appli (compte applicatif, ou SSO de l’établissement), et tu l’identifies par un id interne stable.
{
"objectType": "Agent",
"name": "Awa Diop",
"account": {
"homePage": "https://formacampus.fr",
"name": "user-48213"
}
}⚠️ Piège — Identifier le même apprenant différemment selon la source. S’il est
mboxdans le module web du LMS etaccountdans l’appli mobile, le LRS voit deux acteurs distincts : ses statistiques sont coupées en deux, la reprise cross-device ne marche pas, le tableau de bord ment. Décide d’une stratégie d’identité et applique-la partout — ou tiens une table de correspondance côté back. C’est la décision d’architecture xAPI à ne pas rater.
💡 Réflexe — Préfère un id interne opaque (
account.name = "user-48213") à l’e-mail comme identifiant principal. Un e-mail change (mariage, changement d’établissement) et c’est une donnée personnelle exposée. Un id interne est stable et neutre — tu gardes la correspondance id ↔ personne dans ta base, pas répétée dans chaque statement. On y revient côté vie privée au chapitre 7.5.
Problème n°2 : le réseau qui va et vient
Une appli mobile trace dans le métro, un formateur en atelier sans Wi-Fi fiable, un technicien en zone blanche. Envoyer chaque statement en direct au LRS n’est pas envisageable : il faut capturer maintenant, envoyer plus tard.
Le patron : une file d’attente locale (SQLite, IndexedDB, fichier…) où chaque statement est complet et figé à la capture — surtout son id (UUID) et son timestamp. Un synchroniseur vide la file dès qu’une connexion est là, en PUT idempotent.
Trois exigences non négociables :
timestampà la capture, pas à l’envoi. Sinon un événement de mardi synchronisé jeudi sera daté de jeudi. On horodate l’événement au moment où il se produit (chapitre 7.1).id(UUID) généré à la capture. C’est lui qui rend la synchro idempotente : si l’appli n’est pas sûre qu’un envoi a abouti, elle rejoue le mêmePUTsans risque de doublon.- File persistante. Elle survit à la fermeture de l’appli et à l’extinction du téléphone. Une file en mémoire perd tout au premier crash.
Le connecteur hors-ligne
// File persistante (ici abstraite : queue.add / queue.all / queue.remove).
// En pratique : IndexedDB (web), SQLite/AsyncStorage (mobile).
function captureStatement(partial) {
const statement = {
...partial,
id: crypto.randomUUID(), // (2) id fige a la capture
timestamp: new Date().toISOString(), // (1) heure reelle de l evenement
};
return queue.add(statement); // (3) persiste localement, tout de suite
}
async function syncQueue() {
if (!navigator.onLine) return; // rien a tenter hors ligne
for (const stmt of await queue.all()) {
try {
const res = await fetch(`${LRS}/statements?statementId=${stmt.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": AUTH, // via un proxy serveur cote mobile
"X-Experience-API-Version": "1.0.3",
},
body: JSON.stringify(stmt),
});
// 204 = cree ; 409 = deja present : dans les deux cas, c est envoye.
if (res.status === 204 || res.status === 409) {
await queue.remove(stmt.id);
}
// 400/401 : erreur permanente -> a logguer, sortir de la file "en erreur"
// (ne pas boucler indefiniment sur un statement invalide).
} catch (networkError) {
break; // reseau retombe : on garde la file, on retentera plus tard
}
}
}
// Declencher la synchro au retour du reseau et periodiquement.
window.addEventListener("online", syncQueue);
setInterval(syncQueue, 30_000);💡 Réflexe — Pour vider une grosse file,
POSTun tableau de statements en un appel (chapitre 7.1) : plus efficace que N appels. Chaque statement du tableau garde sonidfigé dans son corps, donc l’idempotence est préservée.PUTun par un reste plus simple à raisonner pour le retrait de file ;POSTpar lots pour la performance. Choisis selon le volume.
⚠️ Piège — Boucler indéfiniment sur un statement invalide. Un
400(statement mal formé) ne se résoudra jamais par un renvoi : si tu le laisses en tête de file, tu bloques toute la synchro derrière lui. Distingue erreur réseau/5xx (on retente) d’erreur client 4xx (on sort le statement de la file de synchro, on le loggue pour investigation). Sans ça, un seul statement pourri gèle toute la remontée.
Le cas de l’atelier présentiel
Cas typique FormaCampus : un formateur, en atelier, valide sur sa tablette que chaque apprenti a réussi un geste technique. Ici, l’acteur du statement n’est pas l’utilisateur de l’appareil (le formateur) mais l’apprenti observé. Le formateur est, lui, l’autorité (qui atteste).
{
"actor": {
"objectType": "Agent",
"name": "Karim Benali",
"account": { "homePage": "https://formacampus.fr", "name": "app-10744" }
},
"verb": {
"id": "https://formacampus.fr/xapi/verbs/a-realise-le-geste",
"display": { "fr-FR": "a réalisé le geste" }
},
"object": {
"objectType": "Activity",
"id": "https://formacampus.fr/xapi/activities/soudure-tig-passe-fond",
"definition": {
"name": { "fr-FR": "Soudure TIG : passe de fond" },
"type": "http://adlnet.gov/expapi/activities/performance"
}
},
"result": { "success": true },
"context": {
"instructor": {
"objectType": "Agent",
"name": "M. Rossi",
"account": { "homePage": "https://formacampus.fr", "name": "formateur-22" }
},
"platform": "Tablette formateur",
"registration": "b7d3c1a0-5e42-4f8a-9c11-3a2b6e0f7d99"
},
"timestamp": "2026-05-06T14:07:00+02:00"
}Deux points d’expert ici : l’acteur est l’apprenti (pas la tablette), et le formateur figure dans context.instructor — le LRS sait qui a fait et qui a attesté. Le context.platform documente la provenance. C’est un niveau de finesse impossible en SCORM.
💡 Réflexe — Un seul appareil, plusieurs acteurs : sur la tablette du formateur, on change d’acteur à chaque apprenti observé. Ne code pas l’acteur en dur au niveau de l’appareil ; fais-en un paramètre de la capture. La tablette est un outil de saisie multi-apprenants, pas le compte d’une personne.
Un seul LRS, plusieurs sources
Tout converge vers un LRS. C’est là que se paie (ou se rentabilise) la cohérence : si module web, appli mobile et tablette utilisent la même identité d’acteur, les mêmes IRI d’activités et un profil de verbes partagé, alors le tableau de bord agrège une vue de l’apprenant. Sinon, tu obtiens un LRS rempli de données… incomparables entre elles. D’où le chapitre suivant.
🧭 Sur FormaCampus — FormaCampus branche trois sources sur son LRS : le module web (dans le LMS des écoles), l’appli mobile (micro-learning hors-ligne, file d’attente + synchro
PUTidempotente), et la tablette formateur en atelier (validation de gestes, acteur = l’apprenti, formateur encontext.instructor). Clé de voûte : une seule identité d’acteur (unaccountavec l’id interne FormaCampus) partagée par les trois. Résultat : le tableau de bord montre, pour chaque apprenant, et ses modules web et son activité mobile et ses validations d’atelier, sur une même timeline. C’est ce que SCORM seul ne permettra jamais.
🔌 Côté intégration — Côté mobile, l’auth Basic vers le LRS ne doit pas vivre dans le binaire de l’appli (décompilable). On passe par un proxy côté serveur FormaCampus : l’appli s’authentifie auprès de son back (session utilisateur), et c’est le back qui détient le secret LRS et relaie les statements. Bonus : le proxy peut valider, enrichir (ajouter
authority,context) et filtrer avant d’écrire dans le LRS. Ne jamais exposer un credential LRS dans un client distribué.
📚 La spec — Les modes d’identification de l’acteur (
mbox,mbox_sha1sum,account,openid), le champcontext(dontinstructor,platform,registration) et l’usage deauthoritysont définis dans la spec xAPI d’ADL (adlnet.gov). xAPI ne prescrit pas de mécanisme de file d’attente : c’est un patron d’implémentation, mais l’idempotence duPUT(viastatementId) et l’immuabilité des statements sont, elles, garanties par la spec — ce sont elles qui le rendent sûr.
✏️ Exercices
Exercice 1 — L’apprenant coupé en deux. Le tableau de bord montre, pour Awa, deux profils séparés : l’un avec son activité web, l’autre avec son activité mobile, jamais réunis. Diagnostic et correctif ?
✅ Solution
Awa est identifiée différemment selon la source : par ex. mbox (mailto:awa.diop@ecole.fr) dans le module web, et account ({homePage, name:"user-48213"}) dans l’appli mobile. Le LRS les traite comme deux acteurs distincts → deux profils. Correctif : adopter une seule stratégie d’identité appliquée à toutes les sources (idéalement un account avec l’id interne stable), ou, si l’historique est déjà pollué, tenir une table de correspondance côté back et réconcilier. C’est la première décision d’architecture d’un déploiement xAPI multi-sources.
Exercice 2 — La date qui saute. Une appli mobile trace hors-ligne le lundi et synchronise le mercredi. Sur le tableau de bord, toutes les activités du lundi apparaissent mercredi. Où est le bug ?
✅ Solution
Le timestamp du statement est posé au moment de l’envoi (mercredi) au lieu du moment de l’événement (lundi) — ou bien il est laissé vide, et le LRS met alors son heure de réception (stored, mercredi). Correctif : figer timestamp: new Date().toISOString() à la capture, dans la file locale, et l’envoyer tel quel à la synchro. Le stored du LRS restera mercredi (normal), mais le timestamp — celui qu’on affiche — dira bien lundi.
Exercice 3 — Le statement qui bloque la file. La synchro d’une appli s’est arrêtée : plus rien ne remonte depuis deux jours, alors que le réseau est bon. En tête de file, un statement renvoie 400 à chaque tentative. Que se passe-t-il et comment coder pour l’éviter ?
✅ Solution
Le statement en tête de file est invalide (400 : mal formé, IRI manquante…). Comme le code ne retire de la file que sur succès et retente en boucle, ce statement pourri bloque tous les suivants — la file ne se vide jamais. Correctif : traiter 4xx comme une erreur permanente (retirer le statement de la file de synchro, le logguer/mettre en quarantaine pour investigation) et ne retenter que sur erreur réseau ou 5xx. Un envoi ne doit jamais pouvoir geler toute la remontée.
🧠 Quiz de révision
1. Pourquoi xAPI peut-il tracer hors du LMS là où SCORM ne peut pas ?
Parce que SCORM ne tracke que dans un navigateur lancé depuis un LMS, via son API JS locale. xAPI, lui, envoie des statements à un LRS par une API REST : n’importe quelle source (appli mobile, tablette, simulateur, terrain) peut émettre, en ligne ou différé. Le LRS ne se soucie pas de la provenance.
2. Comment identifie-t-on l’apprenant sans lancement LMS, et quel est le piège ?
On l’établit soi-même (compte applicatif, SSO), le plus souvent via un account (homePage + id interne stable). Le piège : identifier le même apprenant différemment selon la source (mbox ici, account là) — le LRS voit alors deux acteurs distincts et coupe ses données en deux. Une seule stratégie d’identité, partout.
3. Pourquoi figer l’id et le timestamp à la capture, pas à l’envoi ?
id et le timestamp à la capture, pas à l’envoi ?Le timestamp à la capture préserve l’heure réelle de l’événement malgré une synchro tardive (sinon tout est daté du jour de synchro). L’id (UUID) figé rend la synchro idempotente : rejouer le même PUT après une incertitude réseau ne crée pas de doublon (le LRS reconnaît l’id).
4. Comment éviter qu’un statement invalide bloque toute la file de synchro ?
En distinguant les erreurs : réseau / 5xx → on retente (statement gardé en file) ; 4xx (400/401) → erreur permanente, on sort le statement de la file de synchro et on le loggue. Un 400 ne se résout jamais par un renvoi ; le laisser en tête de file gèle tout ce qui est derrière.
5. Sur la tablette d’un formateur qui valide des gestes, qui est l’acteur du statement ?
L’apprenti observé, pas le formateur ni la tablette. Le formateur, qui atteste, va dans context.instructor ; la provenance dans context.platform. On change d’acteur à chaque apprenti : l’appareil est un outil de saisie multi-apprenants, pas le compte d’une personne.
Chapitre suivant : 7.5 — Profils xAPI & bonnes pratiques — maintenant que tout converge vers un LRS, comment garder ces données exploitables : verbes stables, gouvernance, vie privée.