/** * 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//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' }) ); } }