Skip to content
Merged
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
97 changes: 97 additions & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Course analytics

The course records a small set of first-party events in SQLite's
`course_events` table. The goal is to answer where learners stop or ask for
help without persisting source code, names, URLs, user agents, or arbitrary
client metadata in the analytics table.

Running code still sends the source through this server to the third-party Rust
Playground at `play.rust-lang.org`, just as it did before analytics were added.
The source is needed to execute the program but is not written to
`course_events`; only the resulting counts, timing, and first structured error
code are retained.

## Events

| Event | Recorded when | Result fields |
|---|---|---|
| `chapter_view` | An exercise chapter loads | — |
| `editor_focus` | An editor first receives focus in a browser-tab session | — |
| `hint_opened` | A hint disclosure is first opened | — |
| `solution_revealed` | A full solution is first opened | — |
| `next_chapter_clicked` | The next-chapter CTA is clicked | — |
| `exercise_run` | The server attempts a Rust Playground run | result (`passed`, `test_failed`, `compile_failed`, `no_tests`, `ran`, or `upstream_failed`), tests passed/total, duration, first structured Rust error code |

UI events are deduplicated per `(session_id, event_type, exercise_name)`. Runs
are never deduplicated because repeated runs are the primary difficulty signal.
A session ID is a random UUID kept in `sessionStorage`, so it expires with the
browser tab. `participant_id` is nullable for anonymous learners.

Every row includes `course_version` and `git_hash`, allowing reports to avoid
mixing results from incompatible course revisions.

## Example queries

Exercises with the most repeated unsuccessful runs:

```sql
SELECT
exercise_name,
COUNT(*) AS runs,
COUNT(DISTINCT COALESCE(participant_id, session_id)) AS learners,
ROUND(AVG(result != 'passed') * 100, 1) AS unsuccessful_pct,
ROUND(AVG(duration_ms)) AS average_duration_ms
FROM course_events
WHERE event_type = 'exercise_run' AND result != 'ran'
GROUP BY exercise_name
HAVING learners >= 3
ORDER BY unsuccessful_pct DESC, runs DESC;
```

Hint and solution usage:

```sql
SELECT
exercise_name,
SUM(event_type = 'hint_opened') AS hint_opens,
SUM(event_type = 'solution_revealed') AS solution_reveals
FROM course_events
WHERE event_type IN ('hint_opened', 'solution_revealed')
GROUP BY exercise_name
ORDER BY solution_reveals DESC, hint_opens DESC;
```

Most common structured compiler errors:

```sql
SELECT diagnostic_code, COUNT(*) AS occurrences,
COUNT(DISTINCT COALESCE(participant_id, session_id)) AS learners
FROM course_events
WHERE event_type = 'exercise_run' AND diagnostic_code IS NOT NULL
GROUP BY diagnostic_code
ORDER BY occurrences DESC;
```

Chapter activation from view to editor use:

```sql
WITH events_by_chapter AS (
SELECT *, CASE
WHEN instr(exercise_name, '/') > 0
THEN substr(exercise_name, 1, instr(exercise_name, '/') - 1)
ELSE exercise_name
END AS chapter
FROM course_events
)
SELECT
chapter,
COUNT(DISTINCT CASE WHEN event_type = 'chapter_view' THEN session_id END) AS views,
COUNT(DISTINCT CASE WHEN event_type = 'editor_focus' THEN session_id END) AS editors
FROM events_by_chapter
WHERE event_type IN ('chapter_view', 'editor_focus')
GROUP BY chapter;
```

For reports, prefer a read-only SQLite backup rather than querying the live file.
SQLite's backup API or `.backup` command produces a consistent snapshot without
interrupting the server.
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Two things in one crate:
2. **A small Axum server + CLI** that hosts the same exercises in a
browser and tracks per-participant progress against a SQLite
database. Optional for self-study, required for instructor-led
workshops.
workshops. Privacy-conscious learning events are stored separately in
`course_events`; see `docs/analytics.md`.

The Cargo package is `cargo-course` (Rust edition 2024). It exposes a
library plus two binaries (`server`, `cargo-course` aka the CLI).
Expand All @@ -39,6 +40,7 @@ course/
│ ├── 4_<slug>.rs # another step
│ └── 5_hints.md # optional; slug `hints` is special (see below)
├── migrations/ # SQLx migrations, applied in order at startup
├── docs/analytics.md # Event schema, privacy boundaries, report queries
├── src/
│ ├── lib.rs # re-exports `exercises` and `types`
│ ├── types.rs # API request/response + newtype wrappers
Expand Down
43 changes: 43 additions & 0 deletions migrations/011_course_analytics.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
-- Privacy-conscious course analytics.
--
-- Run outcomes are written by the server after it receives the Rust Playground
-- response. UI events use a small server-side allowlist. No source code,
-- participant names, URLs, user agents, or arbitrary metadata are stored here.
CREATE TABLE course_events (
id TEXT PRIMARY KEY,
participant_id TEXT,
session_id TEXT NOT NULL CHECK(length(session_id) BETWEEN 1 AND 64),
event_type TEXT NOT NULL CHECK(event_type IN (
'chapter_view',
'editor_focus',
'hint_opened',
'solution_revealed',
'next_chapter_clicked',
'exercise_run'
)),
exercise_name TEXT,
result TEXT CHECK(result IS NULL OR result IN (
'passed',
'test_failed',
'compile_failed',
'no_tests',
'ran',
'upstream_failed'
)),
tests_passed INTEGER,
tests_total INTEGER,
duration_ms INTEGER CHECK(duration_ms IS NULL OR duration_ms >= 0),
diagnostic_code TEXT,
course_version TEXT NOT NULL,
git_hash TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (participant_id) REFERENCES participants(id) ON DELETE CASCADE
);

CREATE INDEX idx_course_events_created_at ON course_events(created_at);
CREATE INDEX idx_course_events_participant ON course_events(participant_id, created_at);
CREATE INDEX idx_course_events_exercise ON course_events(exercise_name, event_type, created_at);
CREATE INDEX idx_course_events_session ON course_events(session_id, created_at);
CREATE UNIQUE INDEX idx_course_events_ui_once_per_session
ON course_events(session_id, event_type, exercise_name)
WHERE event_type != 'exercise_run';
27 changes: 27 additions & 0 deletions migrations/012_remove_obsolete_submissions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Remove submissions for exercises that no longer exist in the catalog.
--
-- Migration 010 intentionally retained these rows during the chapter rewrite.
-- They have since been verified against the current catalog and a production
-- snapshot. Keep the cleanup explicit so no valid current key can be removed by
-- a broad numeric-prefix match.
DELETE FROM submissions WHERE exercise_name IN (
'00_integers/3_number_to_string',
'00_integers/4_calculate_total_with_tax',
'03_functions/4_countdown',
'06_vectors/2_count_items',
'09_option/2_fallback',
'16_word_frequencies/4_frequent_words',
'17_password_validator/4_char_classes',
'17_password_validator/6_advisor',
'19_modules_and_visibility/4_settings',
'19_modules_and_visibility/4_status',
'20_environment_file_parser/2_parse_line',
'20_environment_file_parser/3_parse_file',
'20_environment_file_parser/4_get_var',
'20_environment_file_parser/5_validate',
'21_csv_parser/3_simple_line',
'21_csv_parser/4_quoted_line',
'21_csv_parser/5_parse_file',
'21_csv_parser/6_records',
'3_display_name.md'
);
Loading
Loading