Skip to content

xAPI ingestion layer (xAPI-primary) alongside SCORM#85

Merged
erseco merged 7 commits into
mainfrom
feature/xapi-ingestion
Jun 19, 2026
Merged

xAPI ingestion layer (xAPI-primary) alongside SCORM#85
erseco merged 7 commits into
mainfrom
feature/xapi-ingestion

Conversation

@erseco

@erseco erseco commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an xAPI compatibility layer to mod_exelearning, in addition to the existing
SCORM 1.2 bridge. Packages exported by the merged eXeLearning PR #1867 (exe_xapi.js,
verified at commit e3b1bd13) emit xAPI statements by postMessage; this PR consumes them
and grades through the same unit-tested pipeline the SCORM path uses
(track::apply_item_scores / attempts::* / grade_update) — no parallel grade model.

This implements the design already recorded in the research trail: DEC-0032 (dual
ingestion), DEC-0063 (validation/version rules) and DEC-0064 (this implementation),
i.e. task TAREA-015, which was gated on #1867 being merged.

Design decisions

  • xAPI-primary. When a served package bundles libs/xapi/exe_xapi.js, grading flows
    through xAPI and the SCORM shim is kept alive but inert (window.API answers so
    pipwerks/iDevices still run and emit statements, but never POSTs). Legacy packages without
    the emitter keep SCORM grading unchanged. No package ever double-grades.
    • Why it's safe: every gradable iDevice reports through the same
      gamification.scorm.sendScoreNew(), which drives both gamification.track('answered')
      (xAPI) and the pipwerks SCORM call — so xAPI captures exactly what SCORM would.
  • Zero client trust (DEC-0063): the statement actor/authority/stored/timestamp are
    ignored and the grade is attributed to $USER; result.score.scaled must be in [0,1],
    raw in [min,max]; the verb must be whitelisted; statement.id must be a UUID; a null
    outside extensions is rejected; the version is accepted permissively (1.0.x and
    2.0.0). An object.id that does not resolve to a registered iDevice of this instance is
    rejected.
  • Overall from the package statement. Per-iDevice answered statements carry no weight,
    so the authoritative weighted overall is the package passed/failed/completed finalScore,
    taken and validated/clamped server-side (refines DEC-0063 §2; rationale in DEC-0064).
  • Idempotent by statement.id via a new exelearning_tracking_events table.
  • Always on (no setting); gradeenabled remains the only grading switch.
  • Out of scope: cmi5, external-LRS dependency, the core_xapi analytics handler/events
    (deferred to a follow-up).

What's included

Client

  • js/xapi_listener.js — inline IIFE listener (single source of truth, Vitest-tested):
    validates event.origin (rejects '*'/mismatch, RIE-013), de-dups by statement.id,
    forwards to xapi_track.php.
  • js/scorm_tracker.js — opt-in disableTracking inert mode (legacy path byte-for-byte
    unchanged).
  • classes/local/xapi/config_injector.php — pins window.exeXapi.parentOrigin/actor.
  • view.php — channel detection + wiring (shares one page-load token as the xAPI registration).

Server

  • xapi_track.php — sesskey/capability endpoint mirroring track.php.
  • classes/local/xapi/statement_normalizer.php — pure validation + mapping.
  • classes/local/xapi/ingestor.php — orchestration reusing the existing pipeline.
  • db/install.xml + db/upgrade.php (stage 2026061800) — exelearning_tracking_events.
  • classes/privacy/provider.php + lang/* — declare/export/delete the new table.

Tests

  • PHPUnit: tests/local/xapi/statement_normalizer_test.php,
    tests/local/xapi/ingestor_test.php (answered→item parity, package→overall, unknown
    objectid rejected, actor ignored→$USER, idempotency, maxattempt, gradeenabled-off no-op,
    preview).
  • Vitest: tests/js/xapi_listener.test.js (origin/dedup/forward) + a scorm_tracker
    disableTracking case.

Docs (Spanish research)

  • research/decisiones/adr/DEC-0064-implementacion-ingesta-xapi.md (+ FTE-011/TAREA-015
    updates, diary entry); docs/xapi-integration-plan.md + docs/tracking-architecture.md
    flipped to implemented.

Verification

  • phpcs --standard=moodle clean on all changed PHP.
  • Vitest: 28 passing (listener + tracker).
  • Normalizer logic verified standalone (no Moodle deps).
  • PHPUnit (tests/local/xapi/*) runs in CI (needs a Moodle tree).

Compatibility

SCORM 1.2 remains the documented compatibility path (DEC-0003) for legacy packages. No
breaking changes to the existing grade model, schema or APIs.

Update — review round

Acting on the review recommendations:

  • Kill switch. New site admin setting Use xAPI grading when the package supports it (exelearning/xapiprimaryenabled, default on; exelearning_xapi_primary_enabled()). When off, view.php keeps the SCORM shim live and skips the listener and xapi_track.php accept-and-ignores, so a site reverts to SCORM grading with no code change.
  • Scope clarified. This is not cmi5 and not an external-LRS integration; xapi_track.php is a custom grading endpoint, not a full Moodle core_xapi integration. A core_xapi handler for events/analytics (not grading) stays a deferred follow-up (tracked separately). SCORM 1.2 remains the compatibility path.
  • Hardening (review fixes). Registration sanitised + bounded to char(40); result.success read from its correct xAPI location; nil-UUID rejected; record_event rethrows non-duplicate write failures; the listener now inspects the POST result and retries transient non-2xx with bounded backoff (a 409 cap rejection is final).
  • Monitoring. Lost terminal statements (answered without a terminal verb for the same registration) are observable via the exelearning_tracking_events audit log — see docs/tracking-architecture.md.
  • Manual QA. Pre-release checklist with real packages: docs/xapi-qa-checklist.md.

Moodle Playground Preview

The changes in this pull request can be previewed and tested using a Moodle Playground instance.

Preview in Moodle Playground

ℹ️ The eXeLearning editor is fetched from the shared release and unpacked into the plugin when the playground boots, so the first load may take a few extra seconds. ELPX upload, viewer and preview work normally.

erseco and others added 4 commits June 18, 2026 14:09
Consume the xAPI statements emitted by packages exported with the merged
eXeLearning PR #1867 (exe_xapi.js), grading through the existing pipeline
without touching the productive SCORM path.

- js/xapi_listener.js: inline IIFE listener (single source of truth, Vitest
  tested) that validates event.origin, de-dups by statement.id and forwards
  each exe-xapi-statement postMessage to xapi_track.php.
- xapi_track.php: sesskey/capability-authenticated endpoint mirroring track.php.
- classes/local/xapi/statement_normalizer.php: pure DEC-0063 validation +
  mapping (verb whitelist, scaled in [0,1], raw in [min,max], UUID id, null
  only inside extensions, permissive version, objectid from idevice-id
  extension or object.id suffix).
- classes/local/xapi/ingestor.php: reuses track::apply_item_scores /
  attempts:: / grade_update; ignores the actor (grade -> $USER), rejects
  unknown objectids, honours gradeenabled, idempotent by statement.id.
- xAPI-primary: when a package bundles exe_xapi.js the SCORM shim stays alive
  but inert (js/scorm_tracker.js disableTracking) so it cannot double-grade;
  legacy packages keep SCORM grading unchanged.
- Overall taken from the package passed/failed/completed statement (the
  producer's weighted finalScore), validated server-side; answered statements
  carry no weight (refines DEC-0063 §2, see DEC-0064).
- config_injector pins window.exeXapi.parentOrigin/actor (RIE-013).
- New exelearning_tracking_events table (statementid UNIQUE) for
  audit/idempotency; privacy provider + lang strings updated.
- Tests: PHPUnit statement_normalizer + ingestor; Vitest xapi_listener +
  scorm_tracker disableTracking.
- DEC-0064: xAPI-primary coexistence + inert SCORM stub; overall from the
  package statement (refines DEC-0063 §2, grounded in the verified
  answered-has-no-weight finding); always-on; events deferred; plain
  xapi_track.php / inline-IIFE listener rationale. Moves DEC-0032/DEC-0063
  toward Accepted. Cites merged contract e3b1bd13 + upstream security
  additions (PII anonymisation on '*', </script> escaping).
- FTE-011: note #1867 merged at e3b1bd13, contract frozen.
- TAREA-015: status Done.
- docs/xapi-integration-plan.md + tracking-architecture.md: status implemented.
- Regenerated research indices.
Add a focused side-by-side comparison of the two grade channels as implemented
(differences + where each one wins), in English (docs/tracking-architecture.md)
and Spanish (research FTE-009, the canonical standards-comparison source).
@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

erseco and others added 3 commits June 18, 2026 17:34
Review of the xAPI ingestion layer surfaced several robustness gaps; this
fixes the contained ones and pins the edge cases with tests and docs.

- Sanitise + bound the attempt-grouping registration to char(40) /
  PARAM_ALPHANUMEXT in both xapi_track.php (POST body) and the normaliser
  (the context.registration fallback) so a crafted or over-long value cannot
  overflow exelearning_attempt.sessiontoken / _tracking_events.registration.
- Read result.success from its correct xAPI location (not result.score.success).
- Reject the nil UUID (require a real RFC version+variant) so a constant
  statement.id cannot pin idempotency and silently drop later grades.
- Narrow ingestor::record_event to swallow only the genuine UNIQUE(statementid)
  race and rethrow any other audit-write failure.
- js/xapi_listener.js now inspects the POST result and retries transient
  non-2xx / network failures with bounded backoff (mirrors the SCORM
  dirty-resend; a 409 attempt-cap rejection is final), so a grade-bearing
  statement is not silently lost.

Tests: Vitest listener resend cases; PHPUnit normaliser (registration
sanitise/bound, non-string drop, nil-UUID, result.success) and ingestor
(PERITEM per-item publish, long-registration no-overflow, lifecycle audited,
answered-only overall-row edge). Docs: edge-cases section in
tracking-architecture.md + cross-reference in xapi-integration-plan.md.
Acts on the PR review recommendations.

- Feature flag: admin setting "Use xAPI grading when the package supports it"
  (exelearning/xapiprimaryenabled, default on) + helper
  exelearning_xapi_primary_enabled(). When off, view.php keeps the SCORM shim
  live and skips the xAPI listener, and xapi_track.php accept-and-ignores, so a
  site can fall back to SCORM grading without a code change. Strings added in all
  five languages.
- Docs: make explicit that this is NOT cmi5 and NOT an external-LRS integration,
  that xapi_track.php is a custom grading endpoint (not a full core_xapi
  integration; the events/analytics handler stays a deferred follow-up), and that
  SCORM 1.2 remains the compatibility path.
- Monitoring: document how to detect a lost terminal statement (answered rows
  without a passed/failed/completed for the same registration) from the
  exelearning_tracking_events audit log.
- Manual QA: add docs/xapi-qa-checklist.md (legacy SCORM, xAPI multi-iDevice,
  tab-close before terminal, transient-retry, max attempts, preview, grading off,
  PERITEM/OVERALL, kill switch, idempotency, registration hardening, origin).

Test: lib_helpers_test covers the flag default-on and the off/on toggle.
@erseco erseco merged commit 51cb36e into main Jun 19, 2026
25 checks passed
@erseco erseco deleted the feature/xapi-ingestion branch June 19, 2026 11:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants