@learnpack/learnpack 5.0.10 → 5.0.12

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.
@@ -1,353 +1,423 @@
1
- import { IFile } from "../models/file"
2
- import API from "../utils/api"
3
- import Console from "../utils/console"
4
-
5
- const fs = require("fs")
6
-
7
- function createUUID(): string {
8
- return (
9
- Math.random().toString(36).slice(2, 10) +
10
- Math.random().toString(36).slice(2, 10)
11
- )
12
- }
13
-
14
- function stringToBase64(input: string): string {
15
- return Buffer.from(input).toString("base64")
16
- }
17
-
18
- type TCompilationAttempt = {
19
- source_code: string;
20
- stdout: string;
21
- exit_code: number;
22
- starting_at: number;
23
- ending_at: number;
24
- };
25
-
26
- type TTestAttempt = {
27
- source_code: string;
28
- stdout: string;
29
- exit_code: number;
30
- starting_at: number;
31
- ending_at: number;
32
- };
33
-
34
- type TAIInteraction = {
35
- student_message: string;
36
- source_code: string;
37
- ai_response: string;
38
- starting_at: number;
39
- ending_at: number;
40
- };
41
-
42
- export type TStep = {
43
- slug: string;
44
- position: number;
45
- files: IFile[];
46
- is_testeable: boolean;
47
- opened_at?: number; // The time when the step was opened
48
- completed_at?: number; // If the step has tests, the time when all the tests passed, else, the time when the user opens the next step
49
- compilations: TCompilationAttempt[]; // Everytime the user tries to compile the code
50
- tests: TTestAttempt[]; // Everytime the user tries to run the tests
51
- ai_interactions: TAIInteraction[]; // Everytime the user interacts with the AI
52
- };
53
-
54
- type TWorkoutSession = {
55
- started_at: number;
56
- ended_at?: number;
57
- };
58
-
59
- type TStudent = {
60
- token: string;
61
- user_id: string;
62
- email: string;
63
- };
64
-
65
- export interface ITelemetryJSONSchema {
66
- telemetry_id?: string;
67
- user_id?: number | string;
68
- slug: string;
69
- agent?: string;
70
- tutorial_started_at?: number;
71
- last_interaction_at?: number;
72
- steps: Array<TStep>; // The steps should be the same as the exercise
73
- workout_session: TWorkoutSession[]; // It start when the user starts Learnpack, if the last_interaction_at is available, it automatically fills with that
74
- // number and start another session
75
- }
76
-
77
- type TStepEvent = "compile" | "test" | "ai_interaction" | "open_step";
78
-
79
- export type TTelemetryUrls = {
80
- streaming?: string;
81
- batch?: string;
82
- };
83
-
84
- interface ITelemetryManager {
85
- current: ITelemetryJSONSchema | null;
86
- configPath: string | null;
87
- urls: TTelemetryUrls;
88
- salute: (message: string) => void;
89
- start: (
90
- agent: string,
91
- steps: TStep[],
92
- path: string,
93
- tutorialSlug: string
94
- ) => void;
95
- prevStep?: number;
96
- registerStepEvent: (
97
- stepPosition: number,
98
- event: TStepEvent,
99
- data: any
100
- ) => void;
101
- streamEvent: (stepPosition: number, event: string, data: any) => void;
102
- submit: () => Promise<void>;
103
- finishWorkoutSession: () => void;
104
- setStudent: (student: TStudent) => void;
105
- save: () => void;
106
- retrieve: () => Promise<ITelemetryJSONSchema | null>;
107
- }
108
-
109
- const TelemetryManager: ITelemetryManager = {
110
- current: null,
111
- urls: {},
112
- configPath: "",
113
- salute: message => {
114
- Console.info(message)
115
- },
116
-
117
- start: function (agent, steps, path, tutorialSlug) {
118
- this.configPath = path
119
- if (!this.current) {
120
- this.retrieve()
121
- .then(data => {
122
- const prevTelemetry = data
123
- if (prevTelemetry) {
124
- this.current = prevTelemetry
125
- this.finishWorkoutSession()
126
- } else {
127
- this.current = {
128
- telemetry_id: createUUID(),
129
- slug: tutorialSlug,
130
- agent,
131
- tutorial_started_at: Date.now(),
132
- steps,
133
- workout_session: [
134
- {
135
- started_at: Date.now(),
136
- },
137
- ],
138
- }
139
- }
140
-
141
- this.save()
142
- this.submit()
143
- })
144
- .catch(error => {
145
- Console.debug(error)
146
- // Delete the telemetry.json if it exists
147
- fs.unlinkSync(`${this.configPath}/telemetry.json`)
148
- throw new Error(
149
- "There was a problem starting, reload LearnPack\nRun\n$ learnpack start"
150
- )
151
- })
152
- }
153
- },
154
- // verifyStudent: function () {
155
- // if (!this.current) {
156
- // return;
157
- // }
158
-
159
- // if (!this.current.user_id) {
160
-
161
- // }
162
- // },
163
-
164
- setStudent: function (student) {
165
- if (!this.current) {
166
- return
167
- }
168
-
169
- this.current.user_id = student.user_id
170
- this.save()
171
- this.submit()
172
- },
173
- finishWorkoutSession: function () {
174
- if (!this.current) {
175
- return
176
- }
177
-
178
- const lastSession =
179
- this.current?.workout_session[this.current.workout_session.length - 1]
180
- if (
181
- lastSession &&
182
- !lastSession.ended_at &&
183
- this.current?.last_interaction_at
184
- ) {
185
- lastSession.ended_at = this.current.last_interaction_at
186
- this.current.workout_session.push({
187
- started_at: Date.now(),
188
- })
189
- }
190
- },
191
-
192
- registerStepEvent: function (stepPosition, event, data) {
193
- if (!this.current) {
194
- // throw new Error("Telemetry has not been started");
195
- return
196
- }
197
-
198
- const step = this.current.steps[stepPosition]
199
- if (!step) {
200
- return
201
- }
202
-
203
- if (data.source_code) {
204
- data.source_code = stringToBase64(data.source_code)
205
- }
206
-
207
- if (data.stdout) {
208
- data.stdout = stringToBase64(data.stdout)
209
- }
210
-
211
- if (data.stderr) {
212
- data.stderr = stringToBase64(data.stderr)
213
- }
214
-
215
- if (Object.prototype.hasOwnProperty.call(data, "exitCode")) {
216
- data.exit_code = data.exitCode
217
- data.exitCode = undefined
218
- }
219
-
220
- switch (event) {
221
- case "compile":
222
- if (!step.compilations) {
223
- step.compilations = []
224
- }
225
-
226
- step.compilations.push(data)
227
- this.current.steps[stepPosition] = step
228
- break
229
- case "test":
230
- if (!step.tests) {
231
- step.tests = []
232
- }
233
-
234
- // data.stdout =
235
- step.tests.push(data)
236
- if (data.exit_code === 0) {
237
- step.completed_at = Date.now()
238
- }
239
-
240
- this.current.steps[stepPosition] = step
241
- break
242
- case "ai_interaction":
243
- if (!step.ai_interactions) {
244
- step.ai_interactions = []
245
- }
246
-
247
- step.ai_interactions.push(data)
248
- break
249
- case "open_step": {
250
- const now = Date.now()
251
-
252
- if (!step.opened_at) {
253
- step.opened_at = now
254
- this.current.steps[stepPosition] = step
255
- }
256
-
257
- if (this.prevStep || this.prevStep === 0) {
258
- const prevStep = this.current.steps[this.prevStep]
259
- if (!prevStep.is_testeable && !prevStep.completed_at) {
260
- prevStep.completed_at = now
261
- this.current.steps[this.prevStep] = prevStep
262
- }
263
- }
264
-
265
- this.prevStep = stepPosition
266
-
267
- this.submit()
268
- break
269
- }
270
-
271
- default:
272
- throw new Error(`Event type ${event} is not supported`)
273
- }
274
-
275
- this.current.last_interaction_at = Date.now()
276
- this.streamEvent(stepPosition, event, data)
277
- this.save()
278
- },
279
- retrieve: function () {
280
- return new Promise((resolve, reject) => {
281
- fs.readFile(
282
- `${this.configPath}/telemetry.json`,
283
- "utf8",
284
- (err: any, data: any) => {
285
- if (err) {
286
- if (err.code === "ENOENT") {
287
- // File does not exist, resolve with undefined
288
- resolve(null)
289
- } else {
290
- reject(err)
291
- }
292
- } else {
293
- try {
294
- resolve(JSON.parse(data))
295
- } catch (error) {
296
- reject(error)
297
- }
298
- }
299
- }
300
- )
301
- })
302
- },
303
-
304
- submit: async function () {
305
- Console.debug("Submitting telemetry...")
306
-
307
- if (!this.current)
308
- return Promise.resolve()
309
- const url = this.urls.batch
310
- if (!url) {
311
- return
312
- }
313
-
314
- const body = this.current
315
-
316
- API.sendBatchTelemetry(url, body)
317
- },
318
- save: function () {
319
- fs.writeFile(
320
- `${this.configPath}/telemetry.json`,
321
- JSON.stringify(this.current),
322
- (err: any) => {
323
- if (err)
324
- throw err
325
- }
326
- )
327
- },
328
-
329
- streamEvent: async function (stepPosition, event, data) {
330
- if (!this.current)
331
- return
332
-
333
- const url = this.urls.streaming
334
- if (!url) {
335
- return
336
- }
337
-
338
- const stepSlug = this.current.steps[stepPosition].slug
339
-
340
- const body = {
341
- slug: stepSlug,
342
- telemetry_id: this.current.telemetry_id,
343
- user_id: this.current.user_id,
344
- step_position: stepPosition,
345
- event,
346
- data,
347
- }
348
-
349
- API.sendStreamTelemetry(url, body)
350
- },
351
- }
352
-
353
- export default TelemetryManager
1
+ import { IFile } from "../models/file"
2
+ import API from "../utils/api"
3
+ import Console from "../utils/console"
4
+
5
+ const fs = require("fs")
6
+
7
+ function createUUID(): string {
8
+ return (
9
+ Math.random().toString(36).slice(2, 10) +
10
+ Math.random().toString(36).slice(2, 10)
11
+ )
12
+ }
13
+
14
+ function stringToBase64(input: string): string {
15
+ return Buffer.from(input).toString("base64")
16
+ }
17
+
18
+ type TCompilationAttempt = {
19
+ source_code: string
20
+ stdout: string
21
+ exit_code: number
22
+ starting_at: number
23
+ ending_at: number
24
+ }
25
+
26
+ type TTestAttempt = {
27
+ source_code: string
28
+ stdout: string
29
+ exit_code: number
30
+ starting_at: number
31
+ ending_at: number
32
+ }
33
+
34
+ type TAIInteraction = {
35
+ student_message: string
36
+ source_code: string
37
+ ai_response: string
38
+ starting_at: number
39
+ ending_at: number
40
+ }
41
+
42
+ type TQuizSelection = {
43
+ question: string
44
+ answer: string
45
+ isCorrect: boolean
46
+ }
47
+
48
+ type TQuizSubmission = {
49
+ quiz_hash: string
50
+ selections: TQuizSelection[]
51
+ submitted_at: number
52
+ }
53
+
54
+ export type TStep = {
55
+ slug: string
56
+ position: number
57
+ files: IFile[]
58
+ is_testeable: boolean
59
+ opened_at?: number // The time when the step was opened
60
+ completed_at?: number // If the step has tests, the time when all the tests passed, else, the time when the user opens the next step
61
+ compilations: TCompilationAttempt[] // Everytime the user tries to compile the code
62
+ tests: TTestAttempt[] // Everytime the user tries to run the tests
63
+ ai_interactions: TAIInteraction[] // Everytime the user interacts with the AI
64
+ quiz_submissions: TQuizSubmission[] // Everytime the user submits a quiz
65
+ }
66
+
67
+ type TWorkoutSession = {
68
+ started_at: number
69
+ ended_at?: number
70
+ }
71
+
72
+ type TStudent = {
73
+ token: string
74
+ user_id: string
75
+ email: string
76
+ }
77
+
78
+ export interface ITelemetryJSONSchema {
79
+ telemetry_id?: string
80
+ user_id?: number | string
81
+ slug: string
82
+ agent?: string
83
+ tutorial_started_at: number
84
+ last_interaction_at: number
85
+ steps: Array<TStep> // The steps should be the same as the exercise
86
+ workout_session: TWorkoutSession[] // It start when the user starts Learnpack, if the last_interaction_at is available, it automatically fills with that
87
+ // number and start another session
88
+ }
89
+
90
+ type TStepEvent =
91
+ | "compile"
92
+ | "test"
93
+ | "ai_interaction"
94
+ | "open_step"
95
+ | "quiz_submission"
96
+
97
+ export type TTelemetryUrls = {
98
+ streaming?: string
99
+ batch?: string
100
+ }
101
+
102
+ type TUser = {
103
+ token: string
104
+ id: string
105
+ email: string
106
+ }
107
+
108
+ interface ITelemetryManager {
109
+ current: ITelemetryJSONSchema | null
110
+ configPath: string | null
111
+ user: TUser
112
+ urls: TTelemetryUrls
113
+ started: boolean
114
+ salute: (message: string) => void
115
+ start: (
116
+ agent: string,
117
+ steps: TStep[],
118
+ path: string,
119
+ tutorialSlug: string
120
+ ) => void
121
+ prevStep?: number
122
+ registerStepEvent: (
123
+ stepPosition: number,
124
+ event: TStepEvent,
125
+ data: any
126
+ ) => void
127
+ streamEvent: (stepPosition: number, event: string, data: any) => void
128
+ submit: () => Promise<void>
129
+ finishWorkoutSession: () => void
130
+ setStudent: (student: TStudent) => void
131
+ save: () => void
132
+ retrieve: () => Promise<ITelemetryJSONSchema | null>
133
+ }
134
+
135
+ const TelemetryManager: ITelemetryManager = {
136
+ current: null,
137
+ urls: {},
138
+ user: {
139
+ token: "",
140
+ id: "",
141
+ email: "",
142
+ },
143
+ configPath: "",
144
+ started: false,
145
+ salute: message => {
146
+ Console.info(message)
147
+ },
148
+
149
+ start: function (agent, steps, path, tutorialSlug) {
150
+ this.configPath = path
151
+ if (!this.current) {
152
+ this.retrieve()
153
+ .then(data => {
154
+ const prevTelemetry = data
155
+ if (prevTelemetry) {
156
+ this.current = prevTelemetry
157
+ this.finishWorkoutSession()
158
+ } else {
159
+ this.current = {
160
+ telemetry_id: createUUID(),
161
+ slug: tutorialSlug,
162
+ agent,
163
+ tutorial_started_at: Date.now(),
164
+ last_interaction_at: Date.now(),
165
+ steps,
166
+ workout_session: [
167
+ {
168
+ started_at: Date.now(),
169
+ },
170
+ ],
171
+ }
172
+ }
173
+
174
+ if (this.current.user_id) {
175
+ this.user.id = this.current.user_id.toString()
176
+ }
177
+
178
+ this.save()
179
+
180
+ this.started = true
181
+ Console.debug("Telemetry started successfully!")
182
+
183
+ if (!this.user.id) {
184
+ Console.debug(
185
+ "No user ID found, impossible to submit telemetry at start"
186
+ )
187
+ return
188
+ }
189
+
190
+ this.submit()
191
+ })
192
+ .catch(error => {
193
+ Console.debug(error)
194
+ // Delete the telemetry.json if it exists
195
+ fs.unlinkSync(`${this.configPath}/telemetry.json`)
196
+ throw new Error(
197
+ "There was a problem starting, reload LearnPack\nRun\n$ learnpack start"
198
+ )
199
+ })
200
+ }
201
+ },
202
+
203
+ setStudent: function (student) {
204
+ if (!this.current) {
205
+ Console.debug("Telemetry has not been started")
206
+
207
+ return
208
+ }
209
+
210
+ Console.debug("Setting student", student)
211
+
212
+ this.current.user_id = student.user_id
213
+ this.user.id = student.user_id
214
+ this.user.token = student.token
215
+ this.user.email = student.email
216
+ this.save()
217
+ this.submit()
218
+ },
219
+ finishWorkoutSession: function () {
220
+ if (!this.current) {
221
+ return
222
+ }
223
+
224
+ const lastSession =
225
+ this.current?.workout_session[this.current.workout_session.length - 1]
226
+ if (
227
+ lastSession &&
228
+ !lastSession.ended_at &&
229
+ this.current?.last_interaction_at
230
+ ) {
231
+ lastSession.ended_at = this.current.last_interaction_at
232
+ this.current.workout_session.push({
233
+ started_at: Date.now(),
234
+ })
235
+ }
236
+ },
237
+
238
+ registerStepEvent: function (stepPosition, event, data) {
239
+ Console.debug(`Registering Event ${event} for user ${this.user.id}`)
240
+
241
+ if (!this.current) {
242
+ // throw new Error("Telemetry has not been started");
243
+ return
244
+ }
245
+
246
+ const step = this.current.steps[stepPosition]
247
+ if (!step) {
248
+ return
249
+ }
250
+
251
+ if (data.source_code) {
252
+ data.source_code = stringToBase64(data.source_code)
253
+ }
254
+
255
+ if (data.stdout) {
256
+ data.stdout = stringToBase64(data.stdout)
257
+ }
258
+
259
+ if (data.stderr) {
260
+ data.stderr = stringToBase64(data.stderr)
261
+ }
262
+
263
+ if (Object.prototype.hasOwnProperty.call(data, "exitCode")) {
264
+ data.exit_code = data.exitCode
265
+ data.exitCode = undefined
266
+ }
267
+
268
+ switch (event) {
269
+ case "compile":
270
+ if (!step.compilations) {
271
+ step.compilations = []
272
+ }
273
+
274
+ step.compilations.push(data)
275
+ this.current.steps[stepPosition] = step
276
+ break
277
+ case "test":
278
+ if (!step.tests) {
279
+ step.tests = []
280
+ }
281
+
282
+ // data.stdout =
283
+ step.tests.push(data)
284
+ if (data.exit_code === 0) {
285
+ step.completed_at = Date.now()
286
+ }
287
+
288
+ this.current.steps[stepPosition] = step
289
+ break
290
+ case "ai_interaction":
291
+ if (!step.ai_interactions) {
292
+ step.ai_interactions = []
293
+ }
294
+
295
+ step.ai_interactions.push(data)
296
+ break
297
+
298
+ case "quiz_submission": {
299
+ if (!step.quiz_submissions) {
300
+ step.quiz_submissions = []
301
+ }
302
+
303
+ step.quiz_submissions.push(data)
304
+ break
305
+ }
306
+
307
+ case "open_step": {
308
+ const now = Date.now()
309
+
310
+ if (!step.opened_at) {
311
+ step.opened_at = now
312
+ this.current.steps[stepPosition] = step
313
+ }
314
+
315
+ if (this.prevStep || this.prevStep === 0) {
316
+ const prevStep = this.current.steps[this.prevStep]
317
+ if (!prevStep.is_testeable && !prevStep.completed_at) {
318
+ prevStep.completed_at = now
319
+ this.current.steps[this.prevStep] = prevStep
320
+ }
321
+ }
322
+
323
+ this.prevStep = stepPosition
324
+ this.submit()
325
+ break
326
+ }
327
+
328
+ default:
329
+ throw new Error(`Event type ${event} is not supported`)
330
+ }
331
+
332
+ this.current.last_interaction_at = Date.now()
333
+ this.streamEvent(stepPosition, event, data)
334
+ this.save()
335
+ },
336
+ retrieve: function () {
337
+ return new Promise((resolve, reject) => {
338
+ fs.readFile(
339
+ `${this.configPath}/telemetry.json`,
340
+ "utf8",
341
+ (err: any, data: any) => {
342
+ if (err) {
343
+ if (err.code === "ENOENT") {
344
+ // File does not exist, resolve with undefined
345
+ resolve(null)
346
+ } else {
347
+ reject(err)
348
+ }
349
+ } else {
350
+ try {
351
+ resolve(JSON.parse(data))
352
+ } catch (error) {
353
+ reject(error)
354
+ }
355
+ }
356
+ }
357
+ )
358
+ })
359
+ },
360
+
361
+ submit: async function () {
362
+ Console.debug("Submitting telemetry...")
363
+
364
+ if (!this.current) {
365
+ Console.debug("Telemetry has not been started")
366
+ return Promise.resolve()
367
+ }
368
+
369
+ if (!this.user.id) {
370
+ Console.debug("User ID not found, skipping batch telemetry delivery")
371
+ return Promise.resolve()
372
+ }
373
+
374
+ const url = this.urls.batch
375
+ if (!url) {
376
+ Console.debug("Batch URL not found, skipping batch telemetry delivery")
377
+ return Promise.resolve()
378
+ }
379
+
380
+ const body = this.current
381
+
382
+ if (!body.user_id) {
383
+ body.user_id = this.user.id
384
+ }
385
+
386
+ API.sendBatchTelemetry(url, body)
387
+ },
388
+ save: function () {
389
+ fs.writeFile(
390
+ `${this.configPath}/telemetry.json`,
391
+ JSON.stringify(this.current),
392
+ (err: any) => {
393
+ if (err)
394
+ throw err
395
+ }
396
+ )
397
+ },
398
+
399
+ streamEvent: async function (stepPosition, event, data) {
400
+ if (!this.current)
401
+ return
402
+
403
+ const url = this.urls.streaming
404
+ if (!url) {
405
+ return
406
+ }
407
+
408
+ const stepSlug = this.current.steps[stepPosition].slug
409
+
410
+ const body = {
411
+ slug: stepSlug,
412
+ telemetry_id: this.current.telemetry_id,
413
+ user_id: this.current.user_id,
414
+ step_position: stepPosition,
415
+ event,
416
+ data,
417
+ }
418
+
419
+ API.sendStreamTelemetry(url, body)
420
+ },
421
+ }
422
+
423
+ export default TelemetryManager