Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,28 @@ TOROLLO_ALLOWED_ORIGINS=http://<your-lan-ip>:23232

---

## Telemetry

Torollo can send **anonymous, opt-in** usage events so we can see where the roadmaps lose people. **Nothing is ever sent unless you explicitly enable it** — the app asks once on the home screen, and until you answer (or if you decline) it makes zero telemetry requests.

If you opt in, exactly five events are sent, and nothing else:

| Event | Sent when |
|---|---|
| `roadmap_started` | you open a roadmap with no saved progress |
| `step_validated` | a step's validators all pass |
| `step_failed` | a validation runs and the step doesn't pass (engine errors are not counted) |
| `roadmap_completed` | the last remaining step of a roadmap passes |
| `roadmap_abandoned` | you leave a roadmap before finishing it |

Each event carries only: the roadmap id and step id from the catalogue, the app version, and a random install id generated locally (never derived from your machine). No personal data, no project names, no container contents, no code. You can inspect every payload in your browser's network tab.

**Turning it off (or on) later:** click the activity icon in the home-screen header, or clear the `torollo_telemetry_consent` key from the browser's localStorage. Revoking consent also deletes the install id, so re-enabling later starts a fresh anonymous identity.

Forks and self-hosters can point events at their own [Plausible](https://plausible.io)-compatible endpoint (or disable telemetry entirely) at build time with `VITE_TELEMETRY_ENDPOINT` and `VITE_TELEMETRY_DOMAIN` (an empty `VITE_TELEMETRY_ENDPOINT` hard-disables it).

---

## Architecture

* **Backend** — Node.js, Express, TypeScript, Socket.IO, Dockerode. The backend is the supervisor: it drives the local Docker daemon, compiles your visual topology into real `iptables` rules applied inside the containers, and persists state in `~/.torollo/projects.json`. Every node image must ship with `iptables` and `iproute2` — see [Required tooling inside every node image](docs/adding-a-node.md#required-tooling-inside-every-node-image).
Expand Down
137 changes: 137 additions & 0 deletions frontend/src/features/learning/hooks/useLearningPlayer.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useLearningPlayer } from './useLearningPlayer';
import { trackEvent } from '../../telemetry/telemetry';
import type {
Roadmap,
RoadmapProgressResponse,
StepValidationResponse,
} from '../../../shared/types/roadmap';

vi.mock('../../telemetry/telemetry', () => ({ trackEvent: vi.fn() }));
const trackEventMock = vi.mocked(trackEvent);

function jsonResponse(ok: boolean, body: unknown): Response {
return { ok, json: () => Promise.resolve(body) } as Response;
}
Expand Down Expand Up @@ -72,6 +76,7 @@ describe('useLearningPlayer', () => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
trackEventMock.mockClear();
});

afterEach(() => {
Expand Down Expand Up @@ -456,4 +461,136 @@ describe('useLearningPlayer', () => {
expect(result.current.currentStepIndex).toBe(0);
});
});

describe('telemetry events', () => {
const doneStep = { passed: true, attempts: 1, revealedHints: 0 };
const halfDoneProgress: RoadmapProgressResponse = {
projectId: 'p1',
roadmapId: roadmap.id,
steps: { 'create-web-server': doneStep },
};
const allDoneProgress: RoadmapProgressResponse = {
projectId: 'p1',
roadmapId: roadmap.id,
steps: { 'create-web-server': doneStep, 'add-database': doneStep },
};
const failResponse: StepValidationResponse = {
roadmapId: roadmap.id,
stepId: 'create-web-server',
stepPassed: false,
results: [{ index: 0, type: 'container_running', status: 'fail', message: 'Not running.' }],
checkedAt: '2026-07-15T10:00:00.000Z',
};
const errorResponse: StepValidationResponse = {
roadmapId: roadmap.id,
stepId: 'create-web-server',
stepPassed: false,
results: [{ index: 0, type: 'container_running', status: 'error', message: 'Docker down.' }],
checkedAt: '2026-07-15T10:00:00.000Z',
};

it('fires roadmap_started when the persisted progress is empty', async () => {
const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' }));
await openExampleRoadmap(result, fetchMock);
expect(trackEventMock).toHaveBeenCalledWith('roadmap_started', { roadmap: roadmap.id });
});

it('does not fire roadmap_started on a resumed run or an unreachable progress store', async () => {
const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' }));
await openExampleRoadmap(result, fetchMock, halfDoneProgress);

fetchMock.mockResolvedValueOnce(jsonResponse(true, roadmap));
fetchMock.mockRejectedValueOnce(new Error('network down'));
await act(async () => {
await result.current.openRoadmap({ id: roadmap.id, language: 'en' });
});

expect(trackEventMock).not.toHaveBeenCalledWith('roadmap_started', expect.anything());
});

it('fires step_validated on a pass and step_failed on a fail, never on an engine error', async () => {
const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' }));
await openExampleRoadmap(result, fetchMock);

fetchMock.mockResolvedValueOnce(jsonResponse(true, failResponse));
await act(async () => {
await result.current.validateCurrentStep();
});
expect(trackEventMock).toHaveBeenCalledWith('step_failed', {
roadmap: roadmap.id,
step: 'create-web-server',
});

fetchMock.mockResolvedValueOnce(jsonResponse(true, errorResponse));
await act(async () => {
await result.current.validateCurrentStep();
});
expect(trackEventMock).not.toHaveBeenCalledWith('step_validated', expect.anything());

fetchMock.mockResolvedValueOnce(jsonResponse(true, passResponse));
await act(async () => {
await result.current.validateCurrentStep();
});
expect(trackEventMock).toHaveBeenCalledWith('step_validated', {
roadmap: roadmap.id,
step: 'create-web-server',
});
});

it('fires roadmap_completed once, on the validation that completes the roadmap', async () => {
const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' }));
await openExampleRoadmap(result, fetchMock, halfDoneProgress);
expect(result.current.currentStep?.id).toBe('add-database');

fetchMock.mockResolvedValueOnce(jsonResponse(true, { ...passResponse, stepId: 'add-database' }));
await act(async () => {
await result.current.validateCurrentStep();
});
expect(trackEventMock).toHaveBeenCalledWith('roadmap_completed', { roadmap: roadmap.id });

// Re-validating a step of the already complete roadmap must not recount.
fetchMock.mockResolvedValueOnce(jsonResponse(true, { ...passResponse, stepId: 'add-database' }));
await act(async () => {
await result.current.validateCurrentStep();
});
const completions = trackEventMock.mock.calls.filter(([name]) => name === 'roadmap_completed');
expect(completions).toHaveLength(1);
});

it('fires roadmap_abandoned with keepalive when an unfinished roadmap is closed', async () => {
const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' }));
await openExampleRoadmap(result, fetchMock);

act(() => result.current.closeRoadmap());

expect(trackEventMock).toHaveBeenCalledWith(
'roadmap_abandoned',
{ roadmap: roadmap.id, step: 'create-web-server' },
{ keepalive: true }
);
});

it('does not fire roadmap_abandoned when the roadmap is complete', async () => {
const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' }));
await openExampleRoadmap(result, fetchMock, allDoneProgress);

act(() => result.current.closeRoadmap());

expect(trackEventMock).not.toHaveBeenCalledWith(
'roadmap_abandoned',
expect.anything(),
expect.anything()
);
});

it('fires roadmap_abandoned exactly once on unmount with the player open', async () => {
const { result, unmount } = renderHook(() => useLearningPlayer({ projectId: 'p1' }));
await openExampleRoadmap(result, fetchMock);

unmount();

const abandons = trackEventMock.mock.calls.filter(([name]) => name === 'roadmap_abandoned');
expect(abandons).toHaveLength(1);
});
});
});
81 changes: 78 additions & 3 deletions frontend/src/features/learning/hooks/useLearningPlayer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { API_BASE } from '../../../shared/types';
import { readErrorMessage } from '../../../shared/utils/readErrorMessage';
import { trackEvent } from '../../telemetry/telemetry';
import { stepOutcome } from '../validationStatus';
import type {
Roadmap,
RoadmapProgressResponse,
Expand Down Expand Up @@ -57,9 +59,51 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) {
// Two quick openRoadmap clicks race on fetch resolution order; only the
// latest request is allowed to commit its result.
const openSeqRef = useRef(0);
// Telemetry: the open play-through, read by the abandon paths (close,
// unmount, pagehide) — a ref so those handlers see the latest state.
const telemetrySessionRef = useRef<{ roadmapId: string; stepId: string; complete: boolean } | null>(
null
);

const currentStep: RoadmapStep | null = roadmap?.steps[currentStepIndex] ?? null;

useEffect(() => {
if (!roadmap || !currentStep) {
telemetrySessionRef.current = null;
return;
}
telemetrySessionRef.current = {
roadmapId: roadmap.id,
stepId: currentStep.id,
complete: roadmap.steps.every(
step => completedStepIds[step.id] || resultsByStepId[step.id]?.stepPassed
),
};
}, [roadmap, currentStep, completedStepIds, resultsByStepId]);

// Leaving an unfinished roadmap is the abandon event, whatever the exit:
// closing the player, unmounting it, or closing/refreshing the whole tab.
// Nulling the ref makes the event once-per-play-through — close followed by
// unmount must not double-count.
const fireAbandon = useCallback(() => {
const session = telemetrySessionRef.current;
if (!session || session.complete) return;
telemetrySessionRef.current = null;
trackEvent(
'roadmap_abandoned',
{ roadmap: session.roadmapId, step: session.stepId },
{ keepalive: true }
);
}, []);

useEffect(() => {
window.addEventListener('pagehide', fireAbandon);
return () => {
window.removeEventListener('pagehide', fireAbandon);
fireAbandon();
};
}, [fireAbandon]);

const progressUrl = useCallback(
(roadmapId: string) =>
`${API_BASE}/api/learning/progress/${encodeURIComponent(projectId)}/${encodeURIComponent(roadmapId)}`,
Expand Down Expand Up @@ -92,12 +136,14 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) {
// Progress is a convenience: if it can't be read, open fresh anyway.
let progressSteps: Record<string, StepProgress> = {};
let recovered = false;
let progressLoaded = false;
try {
const progressRes = await fetch(progressUrl(data.id));
if (progressRes.ok) {
const progress: RoadmapProgressResponse = await progressRes.json();
progressSteps = progress.steps ?? {};
recovered = progress.storeRecovered === true;
progressLoaded = true;
} else {
console.error('Failed to load roadmap progress: HTTP', progressRes.status);
}
Expand Down Expand Up @@ -129,6 +175,12 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) {
setProgressNotice(recovered);
setValidationError(null);
setResetError(null);

// A confirmed-empty progress store is what "started" means; a failed
// progress fetch could be a resumed run, so it never counts as one.
if (progressLoaded && Object.keys(progressSteps).length === 0) {
trackEvent('roadmap_started', { roadmap: data.id });
}
} catch (err) {
console.error('Failed to load roadmap:', err);
if (seq === openSeqRef.current) setRoadmapError('');
Expand All @@ -140,6 +192,7 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) {
);

const closeRoadmap = useCallback(() => {
fireAbandon();
setRoadmap(null);
setRoadmapError(null);
setCurrentStepIndex(0);
Expand All @@ -149,7 +202,7 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) {
setProgressNotice(false);
setValidationError(null);
setResetError(null);
}, []);
}, [fireAbandon]);

const goToStep = useCallback(
(index: number) => {
Expand Down Expand Up @@ -196,14 +249,36 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) {
}
const response: StepValidationResponse = await res.json();
setResultsByStepId(prev => ({ ...prev, [currentStep.id]: response }));

// An engine error is not a pedagogical failure — it is not counted.
const outcome = stepOutcome(response);
if (outcome !== 'error') {
trackEvent(outcome === 'passed' ? 'step_validated' : 'step_failed', {
roadmap: roadmap.id,
step: currentStep.id,
});
}

// Fresh completion only — this verdict turned the last missing step
// green. Re-validating a step of an already complete roadmap must not
// count the roadmap as completed again.
const stepDone = (step: RoadmapStep) =>
completedStepIds[step.id] || resultsByStepId[step.id]?.stepPassed;
const wasComplete = roadmap.steps.every(stepDone);
const nowComplete =
response.stepPassed &&
roadmap.steps.every(step => step.id === currentStep.id || stepDone(step));
if (nowComplete && !wasComplete) {
trackEvent('roadmap_completed', { roadmap: roadmap.id });
}
} catch (err) {
console.error('Failed to validate step:', err);
setValidationError('');
} finally {
validatingRef.current = false;
setValidating(false);
}
}, [projectId, roadmap, currentStep]);
}, [projectId, roadmap, currentStep, completedStepIds, resultsByStepId]);

/** Forgets this roadmap's persisted progress and restarts it from step 1. */
const resetProgress = useCallback(async () => {
Expand Down
62 changes: 62 additions & 0 deletions frontend/src/features/telemetry/consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
getInstallId,
getTelemetryConsent,
setTelemetryConsent,
subscribeTelemetryConsent,
} from './consent';

describe('telemetry consent', () => {
beforeEach(() => {
localStorage.clear();
});

it('defaults to unset when nothing is stored', () => {
expect(getTelemetryConsent()).toBe('unset');
});

it('round-trips accepted and declined', () => {
setTelemetryConsent('accepted');
expect(getTelemetryConsent()).toBe('accepted');
setTelemetryConsent('declined');
expect(getTelemetryConsent()).toBe('declined');
});

it('treats an unknown stored value as unset', () => {
localStorage.setItem('torollo_telemetry_consent', 'yes-please');
expect(getTelemetryConsent()).toBe('unset');
});

it('falls back to unset when storage throws', () => {
const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage disabled');
});
expect(getTelemetryConsent()).toBe('unset');
spy.mockRestore();
});

it('notifies subscribers on change and stops after unsubscribe', () => {
const listener = vi.fn();
const unsubscribe = subscribeTelemetryConsent(listener);
setTelemetryConsent('accepted');
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
setTelemetryConsent('declined');
expect(listener).toHaveBeenCalledTimes(1);
});

it('keeps the install id stable across calls', () => {
const first = getInstallId();
expect(first).toBeTruthy();
expect(getInstallId()).toBe(first);
});

it('forgets the install id when consent is revoked', () => {
setTelemetryConsent('accepted');
const first = getInstallId();
setTelemetryConsent('declined');
expect(localStorage.getItem('torollo_telemetry_install_id')).toBeNull();
setTelemetryConsent('accepted');
expect(getInstallId()).not.toBe(first);
});
});
Loading
Loading