@lessonkit/xapi 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.cjs +140 -96
- package/dist/index.d.cts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +136 -92
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ Map from telemetry events: `telemetryEventToXAPIStatement(event)` — uses canon
|
|
|
36
36
|
|
|
37
37
|
- No transport → statements queue in memory (dev warns once).
|
|
38
38
|
- Transport failure → re-queue; call `flush()` to retry.
|
|
39
|
+
- Queue capped at **1000** statements by default; oldest dropped when full (`onCap` / `createInMemoryXAPIQueue({ onCap })`).
|
|
39
40
|
- Concurrent `flush()` calls are coalesced.
|
|
40
41
|
|
|
41
42
|
## Docs
|
package/dist/index.cjs
CHANGED
|
@@ -27,15 +27,21 @@ __export(index_exports, {
|
|
|
27
27
|
module.exports = __toCommonJS(index_exports);
|
|
28
28
|
|
|
29
29
|
// src/queue.ts
|
|
30
|
-
|
|
30
|
+
var DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
31
|
+
function createInMemoryXAPIQueue(opts) {
|
|
32
|
+
const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
31
33
|
const buffer = [];
|
|
32
34
|
let flushInFlight = null;
|
|
35
|
+
const notifyDepth = () => {
|
|
36
|
+
opts?.onDepth?.(buffer.length);
|
|
37
|
+
};
|
|
33
38
|
const runFlush = async (transport) => {
|
|
34
39
|
while (buffer.length) {
|
|
35
40
|
const statement = buffer[0];
|
|
36
41
|
try {
|
|
37
42
|
await transport(statement);
|
|
38
43
|
buffer.shift();
|
|
44
|
+
notifyDepth();
|
|
39
45
|
} catch {
|
|
40
46
|
return;
|
|
41
47
|
}
|
|
@@ -44,7 +50,12 @@ function createInMemoryXAPIQueue() {
|
|
|
44
50
|
return {
|
|
45
51
|
enqueue: (statement) => {
|
|
46
52
|
if (statement.id && buffer.some((s) => s.id === statement.id)) return;
|
|
53
|
+
if (buffer.length >= maxSize) {
|
|
54
|
+
buffer.shift();
|
|
55
|
+
opts?.onCap?.();
|
|
56
|
+
}
|
|
47
57
|
buffer.push(statement);
|
|
58
|
+
notifyDepth();
|
|
48
59
|
},
|
|
49
60
|
size: () => buffer.length,
|
|
50
61
|
flush: async (transport) => {
|
|
@@ -59,11 +70,10 @@ function createInMemoryXAPIQueue() {
|
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
// src/client.ts
|
|
62
|
-
var
|
|
73
|
+
var import_core2 = require("@lessonkit/core");
|
|
63
74
|
|
|
64
75
|
// src/telemetryMap.ts
|
|
65
76
|
var import_core = require("@lessonkit/core");
|
|
66
|
-
var import_core2 = require("@lessonkit/core");
|
|
67
77
|
|
|
68
78
|
// src/id.ts
|
|
69
79
|
function cryptoRandomId() {
|
|
@@ -87,94 +97,16 @@ var XAPIVerbs = {
|
|
|
87
97
|
answered: "http://adlnet.gov/expapi/verbs/answered",
|
|
88
98
|
experienced: "http://adlnet.gov/expapi/verbs/experienced"
|
|
89
99
|
};
|
|
90
|
-
function
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
(0, import_core2.buildLessonkitUrn)({ courseId, lessonId }),
|
|
101
|
-
XAPIVerbs.initialized,
|
|
102
|
-
event.timestamp
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
case "lesson_completed": {
|
|
106
|
-
const lessonId = event.lessonId;
|
|
107
|
-
const data = event.data;
|
|
108
|
-
const result = {};
|
|
109
|
-
if (typeof data?.durationMs === "number") {
|
|
110
|
-
result.duration = formatDurationMs(data.durationMs);
|
|
111
|
-
}
|
|
112
|
-
if (typeof data?.success === "boolean") result.success = data.success;
|
|
113
|
-
if (typeof data?.score === "number" || typeof data?.maxScore === "number") {
|
|
114
|
-
const max = typeof data.maxScore === "number" ? data.maxScore : void 0;
|
|
115
|
-
const raw = typeof data.score === "number" ? data.score : void 0;
|
|
116
|
-
result.score = {
|
|
117
|
-
raw,
|
|
118
|
-
max,
|
|
119
|
-
min: 0,
|
|
120
|
-
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
return statementFor((0, import_core2.buildLessonkitUrn)({ courseId, lessonId }), XAPIVerbs.completed, event.timestamp, {
|
|
124
|
-
result: Object.keys(result).length ? result : void 0
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
case "lesson_time_on_task":
|
|
128
|
-
return null;
|
|
129
|
-
case "quiz_answered": {
|
|
130
|
-
const lessonId = event.lessonId;
|
|
131
|
-
const checkId = event.data.checkId;
|
|
132
|
-
const result = {};
|
|
133
|
-
if (typeof event.data.correct === "boolean") {
|
|
134
|
-
result.success = event.data.correct;
|
|
135
|
-
}
|
|
136
|
-
return statementFor(
|
|
137
|
-
(0, import_core2.buildLessonkitUrn)({ courseId, lessonId, checkId }),
|
|
138
|
-
XAPIVerbs.answered,
|
|
139
|
-
event.timestamp,
|
|
140
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
case "quiz_completed": {
|
|
144
|
-
const lessonId = event.lessonId;
|
|
145
|
-
const checkId = event.data.checkId;
|
|
146
|
-
const { score, maxScore } = event.data;
|
|
147
|
-
const result = {};
|
|
148
|
-
if (typeof score === "number" || typeof maxScore === "number") {
|
|
149
|
-
const max = typeof maxScore === "number" ? maxScore : void 0;
|
|
150
|
-
const raw = typeof score === "number" ? score : void 0;
|
|
151
|
-
result.score = {
|
|
152
|
-
raw,
|
|
153
|
-
max,
|
|
154
|
-
min: 0,
|
|
155
|
-
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
return statementFor(
|
|
159
|
-
(0, import_core2.buildLessonkitUrn)({ courseId, lessonId, checkId }),
|
|
160
|
-
XAPIVerbs.completed,
|
|
161
|
-
event.timestamp,
|
|
162
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
case "interaction": {
|
|
166
|
-
const lessonId = event.lessonId;
|
|
167
|
-
const blockId = event.data?.blockId;
|
|
168
|
-
if (!lessonId || !blockId) return null;
|
|
169
|
-
return statementFor(
|
|
170
|
-
(0, import_core2.buildLessonkitUrn)({ courseId, lessonId, blockId }),
|
|
171
|
-
XAPIVerbs.experienced,
|
|
172
|
-
event.timestamp
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
default:
|
|
176
|
-
return (0, import_core.assertNever)(event, "Unhandled telemetry event");
|
|
177
|
-
}
|
|
100
|
+
function buildXapiScoreResult(opts) {
|
|
101
|
+
const max = typeof opts.maxScore === "number" ? opts.maxScore : void 0;
|
|
102
|
+
const raw = typeof opts.score === "number" ? opts.score : void 0;
|
|
103
|
+
if (typeof raw !== "number" && typeof max !== "number") return void 0;
|
|
104
|
+
return {
|
|
105
|
+
raw,
|
|
106
|
+
max,
|
|
107
|
+
min: 0,
|
|
108
|
+
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
109
|
+
};
|
|
178
110
|
}
|
|
179
111
|
function statementFor(objectId, verb, timestamp, extra) {
|
|
180
112
|
return {
|
|
@@ -186,6 +118,113 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
186
118
|
context: extra?.context
|
|
187
119
|
};
|
|
188
120
|
}
|
|
121
|
+
function experiencedBlockStatement(courseId, lessonId, blockId, timestamp) {
|
|
122
|
+
return statementFor(
|
|
123
|
+
(0, import_core.buildLessonkitUrn)({ courseId, lessonId, blockId }),
|
|
124
|
+
XAPIVerbs.experienced,
|
|
125
|
+
timestamp
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
var experiencedBlockMapper = (event, ctx) => {
|
|
129
|
+
if (event.name === "interaction") {
|
|
130
|
+
const lessonId2 = event.lessonId;
|
|
131
|
+
const blockId2 = event.data?.blockId;
|
|
132
|
+
if (!lessonId2 || !blockId2) return null;
|
|
133
|
+
return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
|
|
134
|
+
}
|
|
135
|
+
const lessonId = event.lessonId;
|
|
136
|
+
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
137
|
+
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
138
|
+
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
139
|
+
};
|
|
140
|
+
var TELEMETRY_XAPI_MAPPERS = {
|
|
141
|
+
course_started: (_event, ctx) => statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
142
|
+
course_completed: (_event, ctx) => statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
143
|
+
lesson_started: (event, ctx) => {
|
|
144
|
+
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
145
|
+
if (!lessonId) return null;
|
|
146
|
+
return statementFor(
|
|
147
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }),
|
|
148
|
+
XAPIVerbs.initialized,
|
|
149
|
+
ctx.timestamp
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
lesson_completed: (event, ctx) => {
|
|
153
|
+
if (event.name !== "lesson_completed") return null;
|
|
154
|
+
const lessonId = event.lessonId;
|
|
155
|
+
const data = event.data;
|
|
156
|
+
const result = {};
|
|
157
|
+
if (typeof data?.durationMs === "number") {
|
|
158
|
+
result.duration = formatDurationMs(data.durationMs);
|
|
159
|
+
}
|
|
160
|
+
if (typeof data?.success === "boolean") result.success = data.success;
|
|
161
|
+
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
162
|
+
if (score) result.score = score;
|
|
163
|
+
return statementFor((0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId }), XAPIVerbs.completed, ctx.timestamp, {
|
|
164
|
+
result: Object.keys(result).length ? result : void 0
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
lesson_time_on_task: () => null,
|
|
168
|
+
quiz_answered: (event, ctx) => {
|
|
169
|
+
if (event.name !== "quiz_answered") return null;
|
|
170
|
+
const result = {};
|
|
171
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
172
|
+
return statementFor(
|
|
173
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
174
|
+
XAPIVerbs.answered,
|
|
175
|
+
ctx.timestamp,
|
|
176
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
quiz_completed: (event, ctx) => {
|
|
180
|
+
if (event.name !== "quiz_completed") return null;
|
|
181
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
182
|
+
return statementFor(
|
|
183
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
184
|
+
XAPIVerbs.completed,
|
|
185
|
+
ctx.timestamp,
|
|
186
|
+
{ result: score ? { score } : void 0 }
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
assessment_answered: (event, ctx) => {
|
|
190
|
+
if (event.name !== "assessment_answered") return null;
|
|
191
|
+
const result = {};
|
|
192
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
193
|
+
return statementFor(
|
|
194
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
195
|
+
XAPIVerbs.answered,
|
|
196
|
+
ctx.timestamp,
|
|
197
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
assessment_completed: (event, ctx) => {
|
|
201
|
+
if (event.name !== "assessment_completed") return null;
|
|
202
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
203
|
+
return statementFor(
|
|
204
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
205
|
+
XAPIVerbs.completed,
|
|
206
|
+
ctx.timestamp,
|
|
207
|
+
{ result: score ? { score } : void 0 }
|
|
208
|
+
);
|
|
209
|
+
},
|
|
210
|
+
interaction: experiencedBlockMapper,
|
|
211
|
+
book_page_viewed: experiencedBlockMapper,
|
|
212
|
+
compound_page_viewed: experiencedBlockMapper,
|
|
213
|
+
hotspot_opened: experiencedBlockMapper,
|
|
214
|
+
accordion_section_toggled: experiencedBlockMapper,
|
|
215
|
+
flashcard_flipped: experiencedBlockMapper,
|
|
216
|
+
image_slider_changed: experiencedBlockMapper
|
|
217
|
+
};
|
|
218
|
+
function telemetryEventToXAPIStatement(event) {
|
|
219
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
|
|
220
|
+
if (!mapper) {
|
|
221
|
+
throw new Error(`Unhandled telemetry event: ${event.name}`);
|
|
222
|
+
}
|
|
223
|
+
return mapper(event, {
|
|
224
|
+
courseId: event.courseId,
|
|
225
|
+
timestamp: event.timestamp
|
|
226
|
+
});
|
|
227
|
+
}
|
|
189
228
|
|
|
190
229
|
// src/client.ts
|
|
191
230
|
function isDevEnvironment() {
|
|
@@ -195,7 +234,11 @@ function isDevEnvironment() {
|
|
|
195
234
|
function createXAPIClient(opts) {
|
|
196
235
|
const transport = opts?.transport;
|
|
197
236
|
const courseId = opts?.courseId;
|
|
198
|
-
const queue = opts?.queue ?? createInMemoryXAPIQueue(
|
|
237
|
+
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
238
|
+
maxSize: opts?.maxQueueSize,
|
|
239
|
+
onDepth: opts?.onQueueDepth,
|
|
240
|
+
onCap: opts?.onQueueCap
|
|
241
|
+
});
|
|
199
242
|
let warnedNoTransport = false;
|
|
200
243
|
let warnedTransportFailure = false;
|
|
201
244
|
const inflightById = /* @__PURE__ */ new Map();
|
|
@@ -237,7 +280,8 @@ function createXAPIClient(opts) {
|
|
|
237
280
|
};
|
|
238
281
|
const emit = (event) => {
|
|
239
282
|
const statement = telemetryEventToXAPIStatement(event);
|
|
240
|
-
if (statement)
|
|
283
|
+
if (!statement) return;
|
|
284
|
+
sendOrQueue(statement);
|
|
241
285
|
};
|
|
242
286
|
return {
|
|
243
287
|
send: (statement) => {
|
|
@@ -256,7 +300,7 @@ function createXAPIClient(opts) {
|
|
|
256
300
|
if (!courseId) return;
|
|
257
301
|
emit({
|
|
258
302
|
name: "lesson_started",
|
|
259
|
-
timestamp: (0,
|
|
303
|
+
timestamp: (0, import_core2.nowIso)(),
|
|
260
304
|
courseId,
|
|
261
305
|
lessonId,
|
|
262
306
|
data: { lessonId }
|
|
@@ -272,7 +316,7 @@ function createXAPIClient(opts) {
|
|
|
272
316
|
if (!courseId) return;
|
|
273
317
|
emit({
|
|
274
318
|
name: "lesson_completed",
|
|
275
|
-
timestamp: (0,
|
|
319
|
+
timestamp: (0, import_core2.nowIso)(),
|
|
276
320
|
courseId,
|
|
277
321
|
lessonId,
|
|
278
322
|
data: { lessonId, durationMs, score, maxScore, success }
|
|
@@ -282,7 +326,7 @@ function createXAPIClient(opts) {
|
|
|
282
326
|
if (!courseId) return;
|
|
283
327
|
emit({
|
|
284
328
|
name: "course_completed",
|
|
285
|
-
timestamp: (0,
|
|
329
|
+
timestamp: (0, import_core2.nowIso)(),
|
|
286
330
|
courseId
|
|
287
331
|
});
|
|
288
332
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -52,12 +52,24 @@ type XAPIClient = {
|
|
|
52
52
|
completeCourse: () => void;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
type InMemoryXAPIQueueOptions = {
|
|
56
|
+
/** Maximum queued statements (default 1000). Oldest entries are dropped when full. */
|
|
57
|
+
maxSize?: number;
|
|
58
|
+
/** Called after enqueue with the current queue size. */
|
|
59
|
+
onDepth?: (size: number) => void;
|
|
60
|
+
/** Called when an oldest statement is dropped because the queue is at maxSize. */
|
|
61
|
+
onCap?: () => void;
|
|
62
|
+
};
|
|
63
|
+
declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQueue;
|
|
56
64
|
|
|
57
65
|
declare function createXAPIClient(opts?: {
|
|
58
66
|
transport?: XAPITransport;
|
|
59
67
|
courseId?: CourseId;
|
|
60
68
|
queue?: XAPIQueue;
|
|
69
|
+
/** When creating the default in-memory queue (max size 1000 unless overridden). */
|
|
70
|
+
maxQueueSize?: number;
|
|
71
|
+
onQueueDepth?: (size: number) => void;
|
|
72
|
+
onQueueCap?: () => void;
|
|
61
73
|
}): XAPIClient;
|
|
62
74
|
|
|
63
75
|
/**
|
|
@@ -66,4 +78,4 @@ declare function createXAPIClient(opts?: {
|
|
|
66
78
|
*/
|
|
67
79
|
declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
|
|
68
80
|
|
|
69
|
-
export { type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
|
81
|
+
export { type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
package/dist/index.d.ts
CHANGED
|
@@ -52,12 +52,24 @@ type XAPIClient = {
|
|
|
52
52
|
completeCourse: () => void;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
type InMemoryXAPIQueueOptions = {
|
|
56
|
+
/** Maximum queued statements (default 1000). Oldest entries are dropped when full. */
|
|
57
|
+
maxSize?: number;
|
|
58
|
+
/** Called after enqueue with the current queue size. */
|
|
59
|
+
onDepth?: (size: number) => void;
|
|
60
|
+
/** Called when an oldest statement is dropped because the queue is at maxSize. */
|
|
61
|
+
onCap?: () => void;
|
|
62
|
+
};
|
|
63
|
+
declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQueue;
|
|
56
64
|
|
|
57
65
|
declare function createXAPIClient(opts?: {
|
|
58
66
|
transport?: XAPITransport;
|
|
59
67
|
courseId?: CourseId;
|
|
60
68
|
queue?: XAPIQueue;
|
|
69
|
+
/** When creating the default in-memory queue (max size 1000 unless overridden). */
|
|
70
|
+
maxQueueSize?: number;
|
|
71
|
+
onQueueDepth?: (size: number) => void;
|
|
72
|
+
onQueueCap?: () => void;
|
|
61
73
|
}): XAPIClient;
|
|
62
74
|
|
|
63
75
|
/**
|
|
@@ -66,4 +78,4 @@ declare function createXAPIClient(opts?: {
|
|
|
66
78
|
*/
|
|
67
79
|
declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
|
|
68
80
|
|
|
69
|
-
export { type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
|
81
|
+
export { type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
// src/queue.ts
|
|
2
|
-
|
|
2
|
+
var DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
3
|
+
function createInMemoryXAPIQueue(opts) {
|
|
4
|
+
const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
3
5
|
const buffer = [];
|
|
4
6
|
let flushInFlight = null;
|
|
7
|
+
const notifyDepth = () => {
|
|
8
|
+
opts?.onDepth?.(buffer.length);
|
|
9
|
+
};
|
|
5
10
|
const runFlush = async (transport) => {
|
|
6
11
|
while (buffer.length) {
|
|
7
12
|
const statement = buffer[0];
|
|
8
13
|
try {
|
|
9
14
|
await transport(statement);
|
|
10
15
|
buffer.shift();
|
|
16
|
+
notifyDepth();
|
|
11
17
|
} catch {
|
|
12
18
|
return;
|
|
13
19
|
}
|
|
@@ -16,7 +22,12 @@ function createInMemoryXAPIQueue() {
|
|
|
16
22
|
return {
|
|
17
23
|
enqueue: (statement) => {
|
|
18
24
|
if (statement.id && buffer.some((s) => s.id === statement.id)) return;
|
|
25
|
+
if (buffer.length >= maxSize) {
|
|
26
|
+
buffer.shift();
|
|
27
|
+
opts?.onCap?.();
|
|
28
|
+
}
|
|
19
29
|
buffer.push(statement);
|
|
30
|
+
notifyDepth();
|
|
20
31
|
},
|
|
21
32
|
size: () => buffer.length,
|
|
22
33
|
flush: async (transport) => {
|
|
@@ -34,7 +45,6 @@ function createInMemoryXAPIQueue() {
|
|
|
34
45
|
import { nowIso } from "@lessonkit/core";
|
|
35
46
|
|
|
36
47
|
// src/telemetryMap.ts
|
|
37
|
-
import { assertNever } from "@lessonkit/core";
|
|
38
48
|
import { buildLessonkitUrn } from "@lessonkit/core";
|
|
39
49
|
|
|
40
50
|
// src/id.ts
|
|
@@ -59,94 +69,16 @@ var XAPIVerbs = {
|
|
|
59
69
|
answered: "http://adlnet.gov/expapi/verbs/answered",
|
|
60
70
|
experienced: "http://adlnet.gov/expapi/verbs/experienced"
|
|
61
71
|
};
|
|
62
|
-
function
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
buildLessonkitUrn({ courseId, lessonId }),
|
|
73
|
-
XAPIVerbs.initialized,
|
|
74
|
-
event.timestamp
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
case "lesson_completed": {
|
|
78
|
-
const lessonId = event.lessonId;
|
|
79
|
-
const data = event.data;
|
|
80
|
-
const result = {};
|
|
81
|
-
if (typeof data?.durationMs === "number") {
|
|
82
|
-
result.duration = formatDurationMs(data.durationMs);
|
|
83
|
-
}
|
|
84
|
-
if (typeof data?.success === "boolean") result.success = data.success;
|
|
85
|
-
if (typeof data?.score === "number" || typeof data?.maxScore === "number") {
|
|
86
|
-
const max = typeof data.maxScore === "number" ? data.maxScore : void 0;
|
|
87
|
-
const raw = typeof data.score === "number" ? data.score : void 0;
|
|
88
|
-
result.score = {
|
|
89
|
-
raw,
|
|
90
|
-
max,
|
|
91
|
-
min: 0,
|
|
92
|
-
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
return statementFor(buildLessonkitUrn({ courseId, lessonId }), XAPIVerbs.completed, event.timestamp, {
|
|
96
|
-
result: Object.keys(result).length ? result : void 0
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
case "lesson_time_on_task":
|
|
100
|
-
return null;
|
|
101
|
-
case "quiz_answered": {
|
|
102
|
-
const lessonId = event.lessonId;
|
|
103
|
-
const checkId = event.data.checkId;
|
|
104
|
-
const result = {};
|
|
105
|
-
if (typeof event.data.correct === "boolean") {
|
|
106
|
-
result.success = event.data.correct;
|
|
107
|
-
}
|
|
108
|
-
return statementFor(
|
|
109
|
-
buildLessonkitUrn({ courseId, lessonId, checkId }),
|
|
110
|
-
XAPIVerbs.answered,
|
|
111
|
-
event.timestamp,
|
|
112
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
case "quiz_completed": {
|
|
116
|
-
const lessonId = event.lessonId;
|
|
117
|
-
const checkId = event.data.checkId;
|
|
118
|
-
const { score, maxScore } = event.data;
|
|
119
|
-
const result = {};
|
|
120
|
-
if (typeof score === "number" || typeof maxScore === "number") {
|
|
121
|
-
const max = typeof maxScore === "number" ? maxScore : void 0;
|
|
122
|
-
const raw = typeof score === "number" ? score : void 0;
|
|
123
|
-
result.score = {
|
|
124
|
-
raw,
|
|
125
|
-
max,
|
|
126
|
-
min: 0,
|
|
127
|
-
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
return statementFor(
|
|
131
|
-
buildLessonkitUrn({ courseId, lessonId, checkId }),
|
|
132
|
-
XAPIVerbs.completed,
|
|
133
|
-
event.timestamp,
|
|
134
|
-
{ result: Object.keys(result).length ? result : void 0 }
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
case "interaction": {
|
|
138
|
-
const lessonId = event.lessonId;
|
|
139
|
-
const blockId = event.data?.blockId;
|
|
140
|
-
if (!lessonId || !blockId) return null;
|
|
141
|
-
return statementFor(
|
|
142
|
-
buildLessonkitUrn({ courseId, lessonId, blockId }),
|
|
143
|
-
XAPIVerbs.experienced,
|
|
144
|
-
event.timestamp
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
default:
|
|
148
|
-
return assertNever(event, "Unhandled telemetry event");
|
|
149
|
-
}
|
|
72
|
+
function buildXapiScoreResult(opts) {
|
|
73
|
+
const max = typeof opts.maxScore === "number" ? opts.maxScore : void 0;
|
|
74
|
+
const raw = typeof opts.score === "number" ? opts.score : void 0;
|
|
75
|
+
if (typeof raw !== "number" && typeof max !== "number") return void 0;
|
|
76
|
+
return {
|
|
77
|
+
raw,
|
|
78
|
+
max,
|
|
79
|
+
min: 0,
|
|
80
|
+
scaled: typeof raw === "number" && typeof max === "number" && max > 0 ? raw / max : void 0
|
|
81
|
+
};
|
|
150
82
|
}
|
|
151
83
|
function statementFor(objectId, verb, timestamp, extra) {
|
|
152
84
|
return {
|
|
@@ -158,6 +90,113 @@ function statementFor(objectId, verb, timestamp, extra) {
|
|
|
158
90
|
context: extra?.context
|
|
159
91
|
};
|
|
160
92
|
}
|
|
93
|
+
function experiencedBlockStatement(courseId, lessonId, blockId, timestamp) {
|
|
94
|
+
return statementFor(
|
|
95
|
+
buildLessonkitUrn({ courseId, lessonId, blockId }),
|
|
96
|
+
XAPIVerbs.experienced,
|
|
97
|
+
timestamp
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
var experiencedBlockMapper = (event, ctx) => {
|
|
101
|
+
if (event.name === "interaction") {
|
|
102
|
+
const lessonId2 = event.lessonId;
|
|
103
|
+
const blockId2 = event.data?.blockId;
|
|
104
|
+
if (!lessonId2 || !blockId2) return null;
|
|
105
|
+
return experiencedBlockStatement(ctx.courseId, lessonId2, blockId2, ctx.timestamp);
|
|
106
|
+
}
|
|
107
|
+
const lessonId = event.lessonId;
|
|
108
|
+
const blockId = "data" in event && event.data && "blockId" in event.data ? event.data.blockId : void 0;
|
|
109
|
+
if (!lessonId || !blockId || typeof blockId !== "string") return null;
|
|
110
|
+
return experiencedBlockStatement(ctx.courseId, lessonId, blockId, ctx.timestamp);
|
|
111
|
+
};
|
|
112
|
+
var TELEMETRY_XAPI_MAPPERS = {
|
|
113
|
+
course_started: (_event, ctx) => statementFor(buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.initialized, ctx.timestamp),
|
|
114
|
+
course_completed: (_event, ctx) => statementFor(buildLessonkitUrn({ courseId: ctx.courseId }), XAPIVerbs.completed, ctx.timestamp),
|
|
115
|
+
lesson_started: (event, ctx) => {
|
|
116
|
+
const lessonId = event.name === "lesson_started" ? event.lessonId : void 0;
|
|
117
|
+
if (!lessonId) return null;
|
|
118
|
+
return statementFor(
|
|
119
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId }),
|
|
120
|
+
XAPIVerbs.initialized,
|
|
121
|
+
ctx.timestamp
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
lesson_completed: (event, ctx) => {
|
|
125
|
+
if (event.name !== "lesson_completed") return null;
|
|
126
|
+
const lessonId = event.lessonId;
|
|
127
|
+
const data = event.data;
|
|
128
|
+
const result = {};
|
|
129
|
+
if (typeof data?.durationMs === "number") {
|
|
130
|
+
result.duration = formatDurationMs(data.durationMs);
|
|
131
|
+
}
|
|
132
|
+
if (typeof data?.success === "boolean") result.success = data.success;
|
|
133
|
+
const score = buildXapiScoreResult({ score: data?.score, maxScore: data?.maxScore });
|
|
134
|
+
if (score) result.score = score;
|
|
135
|
+
return statementFor(buildLessonkitUrn({ courseId: ctx.courseId, lessonId }), XAPIVerbs.completed, ctx.timestamp, {
|
|
136
|
+
result: Object.keys(result).length ? result : void 0
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
lesson_time_on_task: () => null,
|
|
140
|
+
quiz_answered: (event, ctx) => {
|
|
141
|
+
if (event.name !== "quiz_answered") return null;
|
|
142
|
+
const result = {};
|
|
143
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
144
|
+
return statementFor(
|
|
145
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
146
|
+
XAPIVerbs.answered,
|
|
147
|
+
ctx.timestamp,
|
|
148
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
quiz_completed: (event, ctx) => {
|
|
152
|
+
if (event.name !== "quiz_completed") return null;
|
|
153
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
154
|
+
return statementFor(
|
|
155
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
156
|
+
XAPIVerbs.completed,
|
|
157
|
+
ctx.timestamp,
|
|
158
|
+
{ result: score ? { score } : void 0 }
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
assessment_answered: (event, ctx) => {
|
|
162
|
+
if (event.name !== "assessment_answered") return null;
|
|
163
|
+
const result = {};
|
|
164
|
+
if (typeof event.data.correct === "boolean") result.success = event.data.correct;
|
|
165
|
+
return statementFor(
|
|
166
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
167
|
+
XAPIVerbs.answered,
|
|
168
|
+
ctx.timestamp,
|
|
169
|
+
{ result: Object.keys(result).length ? result : void 0 }
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
assessment_completed: (event, ctx) => {
|
|
173
|
+
if (event.name !== "assessment_completed") return null;
|
|
174
|
+
const score = buildXapiScoreResult({ score: event.data.score, maxScore: event.data.maxScore });
|
|
175
|
+
return statementFor(
|
|
176
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId: event.lessonId, checkId: event.data.checkId }),
|
|
177
|
+
XAPIVerbs.completed,
|
|
178
|
+
ctx.timestamp,
|
|
179
|
+
{ result: score ? { score } : void 0 }
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
interaction: experiencedBlockMapper,
|
|
183
|
+
book_page_viewed: experiencedBlockMapper,
|
|
184
|
+
compound_page_viewed: experiencedBlockMapper,
|
|
185
|
+
hotspot_opened: experiencedBlockMapper,
|
|
186
|
+
accordion_section_toggled: experiencedBlockMapper,
|
|
187
|
+
flashcard_flipped: experiencedBlockMapper,
|
|
188
|
+
image_slider_changed: experiencedBlockMapper
|
|
189
|
+
};
|
|
190
|
+
function telemetryEventToXAPIStatement(event) {
|
|
191
|
+
const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
|
|
192
|
+
if (!mapper) {
|
|
193
|
+
throw new Error(`Unhandled telemetry event: ${event.name}`);
|
|
194
|
+
}
|
|
195
|
+
return mapper(event, {
|
|
196
|
+
courseId: event.courseId,
|
|
197
|
+
timestamp: event.timestamp
|
|
198
|
+
});
|
|
199
|
+
}
|
|
161
200
|
|
|
162
201
|
// src/client.ts
|
|
163
202
|
function isDevEnvironment() {
|
|
@@ -167,7 +206,11 @@ function isDevEnvironment() {
|
|
|
167
206
|
function createXAPIClient(opts) {
|
|
168
207
|
const transport = opts?.transport;
|
|
169
208
|
const courseId = opts?.courseId;
|
|
170
|
-
const queue = opts?.queue ?? createInMemoryXAPIQueue(
|
|
209
|
+
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
210
|
+
maxSize: opts?.maxQueueSize,
|
|
211
|
+
onDepth: opts?.onQueueDepth,
|
|
212
|
+
onCap: opts?.onQueueCap
|
|
213
|
+
});
|
|
171
214
|
let warnedNoTransport = false;
|
|
172
215
|
let warnedTransportFailure = false;
|
|
173
216
|
const inflightById = /* @__PURE__ */ new Map();
|
|
@@ -209,7 +252,8 @@ function createXAPIClient(opts) {
|
|
|
209
252
|
};
|
|
210
253
|
const emit = (event) => {
|
|
211
254
|
const statement = telemetryEventToXAPIStatement(event);
|
|
212
|
-
if (statement)
|
|
255
|
+
if (!statement) return;
|
|
256
|
+
sendOrQueue(statement);
|
|
213
257
|
};
|
|
214
258
|
return {
|
|
215
259
|
send: (statement) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/xapi",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "xAPI statement generation primitives for LessonKit.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"url": "git+https://github.com/eddiethedean/lessonkit.git",
|
|
10
10
|
"directory": "packages/xapi"
|
|
11
11
|
},
|
|
12
|
-
"homepage": "https://
|
|
12
|
+
"homepage": "https://lessonkit.readthedocs.io/en/latest/reference/xapi.html",
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/eddiethedean/lessonkit/issues"
|
|
15
15
|
},
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"lint": "echo \"(no lint configured yet)\""
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@lessonkit/core": "1.0
|
|
51
|
+
"@lessonkit/core": "1.2.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsup": "^8.5.0",
|