DEV Community

Cover image for What I learned building an xAPI 2.0 library and LRS for .NET
Nurullah Türkoğlu
Nurullah Türkoğlu

Posted on AI-assisted

What I learned building an xAPI 2.0 library and LRS for .NET

xAPI (the Experience API, now IEEE 9274.1.1) is how e-learning content reports what a learner did: "Ada completed Fire Safety, score 0.8" as a JSON statement sent to a Learning Record Store (LRS). The .NET library most projects use for it, TinCan.NET, last changed in 2018. It targets net35, its calls block, and it has no xAPI 2.0 support.

I wrote xapi-dotnet to fill that gap: statement models and validation, an async client with a durable outbox, and an LRS you can embed in an ASP.NET Core app with EF Core on SQLite or PostgreSQL. Version 0.2.0 is on NuGet. Most of what I ran into on the way isn't specific to xAPI.

The conformance suite's exit code wraps at 256

ADL published a conformance test suite for LRSs: a Node.js project that runs 1,365 tests for xAPI 1.0.3 and 1,435 for 2.0.0 against a live endpoint. It is the only shared, executable measure of an LRS, so I put it in CI from the start, first against lrsql (a reference LRS that passes everything) to check the pipeline, then against my own.

The suite's exit code is the number of failed tests. Exit codes are one byte, so 256 failures exit with 0. Tests that never ran are not counted at all, and a wrong password in the setup hook shows up as one failure and 1,434 tests that never ran.

So CI ignores the exit code and reads the suite's JSON log instead. A small .NET file-based app fails the job if the log is for the wrong battery, if the run did not finish, if fewer tests exist than expected, if any test never ran, or if fewer tests passed than a floor stored in the repo. The floor is a ratchet: it only goes up, in the pull request that makes more tests pass. Upstream has no lockfile, so the repo keeps its own and installs the suite with npm ci on Node 22.

The LRS now passes all 1,365 and all 1,435 tests, on both databases, in four CI jobs on every pull request. One failing or skipped test fails the build.

In two places the suite expects more than IEEE 9274.1.1 requires. It still rejects context.revision and context.platform on statements whose object is not an Activity, a 1.0.3 rule that 2.0 dropped (suite issue #278), and it expects a 400 for a statement version of 1.1.0. I followed the suite in both cases and wrote down why in the conformance docs. Otherwise those tests would fail on purpose and need an allowlist, and the allowlists are empty.

Reading JSON by hand

The statement models are read and written with Utf8JsonReader and Utf8JsonWriter, without reflection. The net10.0 builds had to work with Native AOT and trimming, and the netstandard2.0 builds had to survive Unity's IL2CPP code stripping. CI publishes a sample console client with Native AOT and fails on any warning.

Validation also needs to see things a serializer usually smooths over: duplicate keys, unknown properties, null where the standard forbids it, and property name casing. Errors should carry the JSON path of the bad value, for example $.actor.mbox, and the rules differ between 1.0.3 and 2.0.0.

The first version was correct and slow: 8.85 μs and 19.7 KB allocated to read a 1 KB statement. Most of that went into JSON paths built as strings for every property, a hash set per object to find duplicate keys, and property names turned into strings to look them up. Now paths are kept as segments and only formatted when there is an error, known property names are matched on their UTF-8 bytes, and duplicate checks use a bit mask. Reading the same statement takes 5.48 μs and 5.2 KB, about four times the cost of JsonDocument.Parse over the same bytes, and most of what is left is the model itself. Strict mode, which validates, is 2 to 3% slower than lenient mode.

An outbox that survives a crash

Learners lose the network, and apps get suspended or closed in the middle of a lesson. A client that only sends while the network is up loses statements, so Xapi.Client.Outbox puts a queue on disk in front of the client. EnqueueAsync stores the statement and returns; a background loop delivers in order and in batches, retries with backoff, and keeps dead letters with the LRS's response.

Delivery is at least once: a statement leaves the store only after the LRS has answered for it. Resends are safe because the outbox assigns the statement id (a UUIDv7) before storing it. A statement sent twice reaches the LRS with the same id, and a conforming LRS keeps the first copy and answers the second with success or 409. On a 409 the outbox fetches the stored statement and compares it under the standard's equivalence rules, and only treats it as delivered if they match.

The file store flushes its journal to disk before EnqueueAsync returns, and that turned out to be almost all of the cost: 4.7 ms per enqueue on my M1 Pro. On macOS, .NET implements FileStream.Flush(true) with fcntl(F_FULLFSYNC), which asks the SSD to empty its write cache. A plain fsync on the same machine took 52 μs. One outbox therefore stores about 200 statements per second on macOS. I kept the flush and documented the cost. Code that produces statements faster than that should not await each enqueue on a hot path.

SERIALIZABLE on PostgreSQL, and what it costs

Statement writes touch shared rows: the activities and agents that many statements mention. On PostgreSQL the store runs those writes in SERIALIZABLE transactions and retries on serialization failures (SQLSTATE 40001).

With 512 single-statement POSTs from 8 concurrent writers, the store retried about 0.11 times per statement, every request still succeeded, and throughput was about three times that of a single writer. In a one-off comparison, the same load at READ COMMITTED stored 1.4 to 1.9 times as many statements per second with no retries. It would also let through write skews on that path, so SERIALIZABLE stays. SQLite takes the lock up front with BEGIN IMMEDIATE, so writers queue and throughput stays flat at about 640 to 700 statements per second, with no retries.

The benchmark numbers come from one noisy laptop run and are in the docs with the machine details. They say nothing about other LRS products, which I did not measure.

Unity

xAPI is also used from games and simulations built in Unity, and several of the open TinCan.NET issues are about Unity builds. The netstandard2.0 packages install with NuGetForUnity. Tests that parse, validate, sign, send and queue statements pass in the Unity 6.3 editor and in IL2CPP players on macOS and Linux with Managed Stripping Level High and no link.xml. The Linux runs happen in CI on every change.

WebGL took more work. HttpClientHandler can't open connections in a browser, so a small Unity package adds UnityWebRequestHandler, an HttpMessageHandler built on UnityWebRequest.

Even with that handler, the request went out and the response came back, but the await never resumed. The completion callback runs on Unity's main thread, which has Unity's own SynchronizationContext. .NET runs a ConfigureAwait(false) continuation inline only when the thread has no custom context, and queues it to the thread pool otherwise. A WebGL player has no thread pool, so the continuation never ran. The handler now completes its task with the context cleared for that moment. Unity's async file reads also go through the thread pool, so the netstandard2.0 file outbox now reads its journal synchronously.

With those two changes the tests pass in a WebGL build running in headless Chromium. That run is local for now; CI covers the editor and the Linux player. Android and iOS are still untested. The Unity guide has the setup and the test results.

Trying it

dotnet add package Xapi.Client
Enter fullscreen mode Exit fullscreen mode
using Xapi;
using Xapi.Client;

using var http = new HttpClient();
var client = new XapiClient(http, new XapiClientOptions
{
    Endpoint = new Uri("https://lrs.example.com/xapi/"),
    Credentials = XapiCredentials.Basic(key, secret),
});

var id = await client.SaveStatementAsync(new Statement(
    Agent.FromMbox("learner@example.com", "Ada Learner"),
    Verbs.Completed,
    new Activity(new Iri("https://example.com/courses/fire-safety"))));
Enter fullscreen mode Exit fullscreen mode

The LRS also ships as a container image:

docker run -p 8080:8080 -e LrsHost__Key=my-key -e LrsHost__Secret=my-secret -v xapi-lrs-data:/data \
  ghcr.io/nurullahturkoglu/xapi-lrs:0.2.0
Enter fullscreen mode Exit fullscreen mode

The license is Apache-2.0. The version is still 0.x, so the API can change before 1.0. The code and the design decisions are on GitHub, and the docs include a migration guide for TinCan.NET users.

GitHub logo nurullahturkoglu / xapi-dotnet

Experience API (xAPI 2.0 / IEEE 9274.1.1 and 1.0.3) for .NET: models, validation, an LRS client with a durable outbox, and an embeddable LRS

xapi-dotnet

CI License: Apache-2.0

Experience API (xAPI 2.0 / IEEE 9274.1.1, and 1.0.3) for .NET: models and validation, an async LRS client with a durable outbox, and a Learning Record Store you can embed in an ASP.NET Core app.

Status: 0.x. 0.2.0 is on NuGet and the LRS image is on ghcr.io. The API can change between minor versions until 1.0; the 1.0 milestone lists what is left. Documentation: https://nurullahturkoglu.github.io/xapi-dotnet/

The 2.0 standard is IEEE 9274.1.1-2023, also adopted as ISO/IEC/IEEE 39274-1-1:2025.

Packages

Package What it is Targets
Xapi.Core Statement model, strict JSON reading and writing for 1.0.3 and 2.0.0, validation, statement comparison netstandard2.0, net10.0
Xapi.Client Async client for every LRS resource, version negotiation, attachments, statement signing netstandard2.0, net10.0
Xapi.Client.Outbox Durable outbox: retries, error classification, dead letters, idempotent resend netstandard2.0, net10.0
Xapi.Lrs AddXapiLrs / MapXapiLrs: an LRS inside your ASP.NET Core app net10.0
Xapi.Lrs.Sqlite SQLite storage and migrations for Xapi.Lrs net10.0
Xapi.Lrs.PostgreSql
…

If you send xAPI statements from .NET today, I'd like to hear what you use and what is missing.

Top comments (0)