1e51ef7def
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/ - 12 code-workspace-Files - STATUS-*.md - viele M/D/R-Änderungen an bereits getrackten Files - .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
399 lines
14 KiB
JavaScript
399 lines
14 KiB
JavaScript
/**
|
||
* GeoGraSim V2 — TelemetryClient
|
||
*
|
||
* ESM-Bibliothek für V2-Module. Übernimmt:
|
||
* - URL-Parameter-Parsing (session_token, mode, difficulty, lang …)
|
||
* - Profil-Abruf (/api/student/me)
|
||
* - Runtime-Config (/api/runtime-config)
|
||
* - Heartbeat-Loop mit Plattform-Frequenz (clamped 10–30s default)
|
||
* - Stuck-Detection (Default 90s, aus runtime-config)
|
||
* - Milestone- und Accessibility-Blocker-Events
|
||
* - State-Save (/api/module/state PUT)
|
||
* - Result-POST am Ende
|
||
* - Tab-Close via navigator.sendBeacon
|
||
* - Mock-Erkennung (?mock=1 in URL)
|
||
*
|
||
* Token-Refresh: Stub vorhanden, echte Implementation folgt mit V2-Login.
|
||
* Bei 401 → Lib redirected zu /v2beta/ (Login).
|
||
*
|
||
* Verwendung im Modul (siehe v2-modules/hallo-welt/public/game.html):
|
||
*
|
||
* import { TelemetryClient } from '../../../v2-platform/lib/telemetry-client.js';
|
||
*
|
||
* const tc = new TelemetryClient({
|
||
* moduleSlug: 'hallo-welt',
|
||
* moduleVersion: '1.0.0',
|
||
* specVersion: '1.0',
|
||
* });
|
||
* await tc.init();
|
||
*
|
||
* tc.setPhase('welcome', 'Begrüßung');
|
||
* tc.milestone('welcome-shown', 'Begrüßung gesehen', 1);
|
||
* tc.updateScore(score, 100);
|
||
*
|
||
* await tc.complete({
|
||
* completed: true,
|
||
* lehrplanCoverage: { 'AT-GW-1.4.2': 'full' },
|
||
* });
|
||
*/
|
||
|
||
export class TelemetryClient {
|
||
/**
|
||
* @param {object} options
|
||
* @param {string} options.moduleSlug — z.B. "hallo-welt"
|
||
* @param {string} options.moduleVersion — z.B. "1.0.0"
|
||
* @param {string} options.specVersion — z.B. "1.0"
|
||
* @param {number} [options.heartbeatPreferenceS=15] — Modul-Wunsch, wird auf runtime-config-Grenzen geclampt
|
||
* @param {(profile:object) => void} [options.onProfileLoaded]
|
||
* @param {(err:Error) => void} [options.onError]
|
||
* @param {(reason:string) => void} [options.onTokenExpired] — wenn null, default: redirect zu /v2beta/
|
||
* @param {(info:{idleS:number,phase:string}) => void} [options.onStuck] — UI-Hook: Banner einblenden
|
||
* @param {() => void} [options.onUnstuck] — UI-Hook: Banner ausblenden, wenn wieder Aktivität
|
||
*/
|
||
constructor(options) {
|
||
if (!options?.moduleSlug || !options?.moduleVersion || !options?.specVersion) {
|
||
throw new Error('TelemetryClient: moduleSlug, moduleVersion, specVersion sind Pflicht.');
|
||
}
|
||
this.opts = {
|
||
heartbeatPreferenceS: 15,
|
||
...options,
|
||
};
|
||
|
||
// URL-Parameter parsen
|
||
const params = new URLSearchParams(location.search);
|
||
this.session = {
|
||
sessionToken: params.get('session_token') || '',
|
||
studentId: parseInt(params.get('student_id') || '0', 10),
|
||
classId: parseInt(params.get('class_id') || '0', 10),
|
||
mode: params.get('mode') || 'free',
|
||
difficulty: params.get('difficulty') || 'L1',
|
||
lang: params.get('lang') || 'de-AT-standard',
|
||
resume: params.get('resume') === '1',
|
||
isMock: params.get('mock') === '1',
|
||
};
|
||
|
||
if (!this.session.sessionToken) {
|
||
throw new Error('TelemetryClient: kein session_token in URL.');
|
||
}
|
||
|
||
// API-Base: Mock oder Echt
|
||
// Pfad: von v2-modules/<slug>/public/game.html drei Ebenen hoch nach Repo-Root
|
||
this.apiBase = this.session.isMock
|
||
? '../../../v2-platform/mock/api'
|
||
: '../../../v2-platform/php/api';
|
||
|
||
// Runtime-State
|
||
this.profile = null;
|
||
this.config = null;
|
||
this.phase = null;
|
||
this.phaseLabel = null;
|
||
this.step = null;
|
||
this.score = 0;
|
||
this.scoreMax = 0;
|
||
this.startTs = Date.now();
|
||
this.phaseStartTs = Date.now();
|
||
this.lastActivityTs = Date.now();
|
||
this._heartbeatTimer = null;
|
||
this._stuckTimer = null;
|
||
this._stuckActive = false;
|
||
this._activityHandlers = null;
|
||
this._destroyed = false;
|
||
}
|
||
|
||
// ─── INIT ──────────────────────────────────────────────────────────
|
||
|
||
async init() {
|
||
// 1) runtime-config holen
|
||
this.config = await this._fetch('runtime-config.php', { method: 'GET' });
|
||
|
||
// 2) Profil holen
|
||
const profileRes = await this._fetch('student/me.php', { method: 'GET' });
|
||
this.profile = profileRes;
|
||
this.opts.onProfileLoaded?.(profileRes);
|
||
|
||
// 3) Activity-Tracking starten
|
||
this._activityHandlers = {
|
||
activity: () => { this.lastActivityTs = Date.now(); this._clearStuck(); },
|
||
visibility: () => {
|
||
if (document.visibilityState === 'visible') {
|
||
this.lastActivityTs = Date.now();
|
||
}
|
||
},
|
||
};
|
||
['click', 'touchstart', 'keydown'].forEach(e =>
|
||
document.addEventListener(e, this._activityHandlers.activity, { passive: true })
|
||
);
|
||
document.addEventListener('visibilitychange', this._activityHandlers.visibility);
|
||
|
||
// 4) Tab-Close-Beacon registrieren
|
||
addEventListener('beforeunload', () => this._sendFinalBeacon());
|
||
|
||
// 5) Heartbeat-Loop starten
|
||
this._startHeartbeat();
|
||
|
||
// 6) Stuck-Check starten
|
||
this._startStuckChecker();
|
||
|
||
return this;
|
||
}
|
||
|
||
// ─── PHASE / SCORE ─────────────────────────────────────────────────
|
||
|
||
setPhase(phase, phaseLabel = null, step = null) {
|
||
this.phase = phase;
|
||
this.phaseLabel = phaseLabel || phase;
|
||
this.step = step;
|
||
this.phaseStartTs = Date.now();
|
||
this.lastActivityTs = Date.now();
|
||
this._clearStuck();
|
||
// sofort Heartbeat senden, damit Lehrperson den Wechsel sieht
|
||
this._sendHeartbeat();
|
||
}
|
||
|
||
updateScore(score, scoreMax = null) {
|
||
this.score = score;
|
||
if (scoreMax !== null) this.scoreMax = scoreMax;
|
||
}
|
||
|
||
// ─── EVENTS ────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Milestone emittieren.
|
||
* @param {string} id — muss in manifest.telemetryMilestones deklariert sein
|
||
* @param {string} label — sichtbarer Text
|
||
* @param {number} [scoreDelta=0]
|
||
*/
|
||
milestone(id, label, scoreDelta = 0) {
|
||
this.score += scoreDelta;
|
||
return this._post('telemetry.php', {
|
||
type: 'milestone',
|
||
milestoneId: id,
|
||
milestoneLabel: label,
|
||
scoreDelta,
|
||
currentScore: this.score,
|
||
phase: this.phase,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Accessibility-Blocker — wenn Modul für aktuelle Schüler*in nicht weiterspielbar ist.
|
||
* @param {object} info
|
||
* @param {'visual-required'|'audio-required'|'mouse-required'|'motor-required'} info.blockerType
|
||
* @param {string} info.hint
|
||
*/
|
||
accessibilityBlocker({ blockerType, hint }) {
|
||
return this._post('telemetry.php', {
|
||
type: 'accessibility-blocker',
|
||
blockerType,
|
||
hint,
|
||
phase: this.phase,
|
||
phaseLabel: this.phaseLabel,
|
||
});
|
||
}
|
||
|
||
// ─── STATE (Resume-Daten) ──────────────────────────────────────────
|
||
|
||
async saveState(state, summary = '') {
|
||
return this._post('module/state.php', {
|
||
moduleSlug: this.opts.moduleSlug,
|
||
state,
|
||
summary,
|
||
}, 'PUT');
|
||
}
|
||
|
||
// ─── ABSCHLUSS ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Ergebnis bei Modul-Abschluss senden + Cleanup.
|
||
* Modul ruft das EINMAL pro Sitzung, danach destroy().
|
||
*/
|
||
async complete(result) {
|
||
const body = {
|
||
moduleSlug: this.opts.moduleSlug,
|
||
moduleVersion: this.opts.moduleVersion,
|
||
specVersion: this.opts.specVersion,
|
||
completed: true,
|
||
abandoned: false,
|
||
score: this.score,
|
||
scoreMax: this.scoreMax || 100,
|
||
durationS: this.elapsedTotalS(),
|
||
difficulty: this.session.difficulty,
|
||
mode: this.session.mode,
|
||
milestonesReached: [],
|
||
lehrplanCoverage: {},
|
||
ts: new Date().toISOString(),
|
||
...result,
|
||
};
|
||
const res = await this._post('result.php', body);
|
||
return res;
|
||
}
|
||
|
||
/**
|
||
* Cleanup: Timer stoppen, Event-Listener entfernen.
|
||
* Wird von complete() / exit() automatisch aufgerufen.
|
||
*/
|
||
destroy() {
|
||
if (this._destroyed) return;
|
||
this._destroyed = true;
|
||
if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
|
||
if (this._stuckTimer) clearInterval(this._stuckTimer);
|
||
if (this._activityHandlers) {
|
||
['click', 'touchstart', 'keydown'].forEach(e =>
|
||
document.removeEventListener(e, this._activityHandlers.activity)
|
||
);
|
||
document.removeEventListener('visibilitychange', this._activityHandlers.visibility);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Modul-Abbruch (z.B. User klickt ✕).
|
||
*/
|
||
exit(reason = 'user-exit') {
|
||
this._post('telemetry.php', {
|
||
type: 'milestone',
|
||
milestoneId: 'module-aborted',
|
||
milestoneLabel: `Modul abgebrochen (${reason})`,
|
||
phase: this.phase,
|
||
});
|
||
this.destroy();
|
||
}
|
||
|
||
// ─── UTILS für Modul-Code ──────────────────────────────────────────
|
||
|
||
elapsedTotalS() { return Math.floor((Date.now() - this.startTs) / 1000); }
|
||
elapsedPhaseS() { return Math.floor((Date.now() - this.phaseStartTs) / 1000); }
|
||
|
||
// ─── INTERN ────────────────────────────────────────────────────────
|
||
|
||
_startHeartbeat() {
|
||
const min = this.config?.heartbeat?.minIntervalS || 10;
|
||
const max = this.config?.heartbeat?.maxIntervalS || 30;
|
||
const clamped = Math.min(max, Math.max(min, this.opts.heartbeatPreferenceS));
|
||
this._heartbeatTimer = setInterval(() => {
|
||
// Bei Inaktivität > 60s nichts senden (User wahrscheinlich AFK)
|
||
if (Date.now() - this.lastActivityTs > 60_000) return;
|
||
this._sendHeartbeat();
|
||
}, clamped * 1000);
|
||
// Initial-Heartbeat
|
||
setTimeout(() => this._sendHeartbeat(), 100);
|
||
}
|
||
|
||
_sendHeartbeat() {
|
||
return this._post('telemetry.php', {
|
||
type: 'heartbeat',
|
||
phase: this.phase || 'init',
|
||
phaseLabel: this.phaseLabel || 'Initialisierung',
|
||
step: this.step,
|
||
currentScore: this.score || undefined,
|
||
scoreMax: this.scoreMax || undefined,
|
||
timeInPhaseS: this.elapsedPhaseS(),
|
||
totalTimeS: this.elapsedTotalS(),
|
||
});
|
||
}
|
||
|
||
_startStuckChecker() {
|
||
const threshold = this.config?.stuck?.thresholdS || 90;
|
||
this._stuckTimer = setInterval(() => {
|
||
if (this._stuckActive) return;
|
||
if (document.visibilityState !== 'visible') return;
|
||
const idleS = Math.floor((Date.now() - this.lastActivityTs) / 1000);
|
||
if (idleS >= threshold) {
|
||
this._stuckActive = true;
|
||
const info = { idleS, phase: this.phase };
|
||
this._post('telemetry.php', {
|
||
type: 'stuck',
|
||
phase: this.phase,
|
||
phaseLabel: this.phaseLabel,
|
||
step: this.step,
|
||
idleS,
|
||
hint: `Inaktiv in Phase '${this.phase}' seit ${idleS}s`,
|
||
});
|
||
this.opts.onStuck?.(info);
|
||
}
|
||
}, 10_000);
|
||
}
|
||
|
||
_clearStuck() {
|
||
if (this._stuckActive) {
|
||
this._stuckActive = false;
|
||
this.opts.onUnstuck?.();
|
||
}
|
||
}
|
||
|
||
// ─── HTTP ──────────────────────────────────────────────────────────
|
||
|
||
async _fetch(endpoint, opts = {}) {
|
||
const url = `${this.apiBase}/${endpoint}`;
|
||
const res = await fetch(url, {
|
||
method: opts.method || 'GET',
|
||
headers: {
|
||
Authorization: `Bearer ${this.session.sessionToken}`,
|
||
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
|
||
},
|
||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||
});
|
||
if (res.status === 401) {
|
||
this._handleTokenExpired('401-on-' + endpoint);
|
||
throw new Error(`401 Unauthorized auf ${endpoint}`);
|
||
}
|
||
if (!res.ok) {
|
||
const err = new Error(`HTTP ${res.status} auf ${endpoint}`);
|
||
this.opts.onError?.(err);
|
||
throw err;
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
/**
|
||
* Telemetry-Post mit milder Fehlertoleranz (silent retry max 1×, dann verwerfen).
|
||
*/
|
||
async _post(endpoint, body, method = 'POST') {
|
||
const full = {
|
||
moduleSlug: this.opts.moduleSlug,
|
||
moduleVersion: this.opts.moduleVersion,
|
||
ts: new Date().toISOString(),
|
||
...body,
|
||
};
|
||
try {
|
||
return await this._fetch(endpoint, { method, body: full });
|
||
} catch (e) {
|
||
// Telemetry blockiert Modul nicht — Fehler still verschluckt
|
||
return null;
|
||
}
|
||
}
|
||
|
||
_handleTokenExpired(reason) {
|
||
if (this.opts.onTokenExpired) {
|
||
this.opts.onTokenExpired(reason);
|
||
} else {
|
||
// Default: zurück zur V2-Login-Seite
|
||
// Aber: nur wenn nicht in iframe (Mock-Test-Harness lädt im iframe)
|
||
if (window.parent === window) {
|
||
location.href = '/v2beta/';
|
||
} else {
|
||
console.warn(`[TelemetryClient] Token expired (${reason}). Im iframe — kein Auto-Redirect.`);
|
||
}
|
||
}
|
||
}
|
||
|
||
_sendFinalBeacon() {
|
||
if (!navigator.sendBeacon || this._destroyed) return;
|
||
const body = JSON.stringify({
|
||
type: 'heartbeat',
|
||
moduleSlug: this.opts.moduleSlug,
|
||
moduleVersion: this.opts.moduleVersion,
|
||
phase: 'tab-closed',
|
||
phaseLabel: 'Tab geschlossen',
|
||
currentScore: this.score || undefined,
|
||
scoreMax: this.scoreMax || undefined,
|
||
timeInPhaseS: this.elapsedPhaseS(),
|
||
totalTimeS: this.elapsedTotalS(),
|
||
ts: new Date().toISOString(),
|
||
});
|
||
navigator.sendBeacon(
|
||
`${this.apiBase}/telemetry.php?session_token=${encodeURIComponent(this.session.sessionToken)}`,
|
||
new Blob([body], { type: 'application/json' })
|
||
);
|
||
}
|
||
}
|