feat(dimensional): add a user_fk-keyed learner x course run x day activity fact - #2672
Open
blarghmatey wants to merge 5 commits into
Open
feat(dimensional): add a user_fk-keyed learner x course run x day activity fact#2672blarghmatey wants to merge 5 commits into
blarghmatey wants to merge 5 commits into
Conversation
…ivity fact Per-learner activity exists only in organization_administration_report, which groups by dim_user.email. That email is a coalesce across a person's accounts and moves when they edit it, so joining the report back to a user drops or mis-attributes learners. The learner-records API needs last_active_on, days_active and per-run activity counts keyed on the same user_fk as every other fact, and the aggregate b2b views can use the same table. afact_learner_courserun_daily_activity rolls the five event facts up to (platform, user_fk, course run, day), with the report's day boundary and metric definitions except: - problems_attempted counts problem_check only (the report also counts showanswer) - chatbot submits with no block_id still count (the report's session_id || block_id drops them) - Canvas chatbot events are excluded: their course ids are not Open edX runs Rebuilt in full rather than incremental, because user_pk can still re-key and an incremental table would keep old days under the stale key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FmjwANjfgtuKAtsRPf6Ly
🔎 ol-dbt impact — column-level blast radius0 breaking, 0 surrogate-key regeneration, 0 warning, 1 info across 1 changed model(s). Details
Posted by |
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Upstream stale user keys and session-dependent date bucketing can produce incorrect learner activity records.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds a full-rebuild learner/course-run/day activity fact for the learner-records API.
Changes:
- Aggregates five event facts by learner, course run, platform, and day.
- Documents grain, metrics, foreign keys, and dbt tests.
File summaries
| File | Description |
|---|---|
afact_learner_courserun_daily_activity.sql |
Builds the daily activity fact. |
_dim__models.yml |
Documents and tests the new model. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ty fact tfact_chatbot_events resolves user_fk through a left join to dim_user, so it can be null. The other four event sources already filter those rows; without the same filter here a chatbot-only day could enter the fact with a null key and fail its not_null test. Found by Sentry review on #2672. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FmjwANjfgtuKAtsRPf6Ly
Sentry review on #2672 flagged the constant 'mitxonline' as platform in chatbot_days' select list with no matching group by entry. Grouping by the constant costs nothing on any engine and removes the question of whether Trino accepts it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FmjwANjfgtuKAtsRPf6Ly
…oblem-event keys Both from Copilot review on #2672. The day bucket was cast(event_timestamp as date), which Trino evaluates in each value's own offset but DuckDB evaluates in the session zone, so the same input produced different rows per engine. Measured on production: 15.2M of tfact_discussion_events' 17.5M rows carry a local offset rather than Z, and 2.9M of those fall on a different day under UTC. Every event fact already carries date_fk, the dim_date key derived from event_timestamp_iso8601 in the source's own timezone -- the same day Trino's cast produces. The fact now groups on date_fk and emits activity_date_key (int YYYYMMDD, FK to dim_date) instead of a date column, so the result no longer depends on the engine or the session. tfact_problem_events is incremental and its watermarks only re-select events newer than the last run, so a dim_user re-key left historical rows under the obsolete user_fk -- the hazard tfact_grade and tfact_certificate already guard against with a stale_user_fk_lookup. It now reads back rows at or below the watermark whose stored user_fk no longer matches what dim_user resolves, re-resolves the key per platform, and unions them in; delete+insert on event_id replaces them. Rows above the watermark are excluded so an event the source CTEs re-select cannot also arrive from there and double-insert. The incremental branch is not exercised by dbt parse or sqlfluff, both of which render is_incremental() as false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FmjwANjfgtuKAtsRPf6Ly
… keys The stale-key CTEs left join dim_user, so a learner who no longer resolves there -- removed, or a username change that moves the join keys -- produced a null users.user_pk, which `is distinct from` the stored key and so re-selected the row and wrote the null back over a good value. Requiring the re-resolved key to exist means a row can be corrected but never blanked. Found by Sentry review on #2672. Sentry suggested copying tfact_grade's stale_user_fk_lookup instead. That pattern compares against rows re-derived from source in the same run, which is exactly what these rows are not: the watermark excludes them, which is why they go stale in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FmjwANjfgtuKAtsRPf6Ly
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What are the relevant tickets?
N/A. Part of the learner-records API work (spec: mitodl/ol-analytics-api#55,
docs/b2b-learner-records-design.md§1 gap 2).Description (What does it do?)
Adds
afact_learner_courserun_daily_activity: one row per (platform,user_fk, course run, day) with any tracked course activity, rolled up from the five event facts.Per-learner activity exists today only in
organization_administration_report, which groups bydim_user.email. That email is a coalesce across a person's accounts and moves when they edit it, so joining the report back to a learner drops or mis-attributes some. The learner-records API needslast_active_on,days_activeand per-run activity counts keyed on the sameuser_fkas the enrollment, grade and certificate facts.Columns:
videos_played,problems_attempted,navigation_events,discussion_events,chatbot_interactions.last_active_onismax(activity_date_key)anddays_activeiscount(distinct activity_date_key)over this table.The day is each event's
date_fk(int YYYYMMDD, FK todim_date), emitted asactivity_date_key. It started ascast(event_timestamp as date), which Trino evaluates in each value's own offset but DuckDB evaluates in the session zone, so the same input produced different rows per engine.date_fkis derived fromevent_timestamp_iso8601in the source's own timezone, which is the day Trino's cast already produced.Metric definitions follow the report, except:
problems_attemptedcountsproblem_checkonly. The report'sproblems_countalso countsshowanswer.block_idstill count. The report'ssession_id || block_idmakes them null and drops them.active_countcounts certificates.It's a full-rebuild table (the dimensional default), not incremental, because
user_pkcan still re-key and an incremental table would keep old days under a stale key.This also changes
tfact_problem_events(from review). It is incremental, and its watermarks only re-select events newer than the last run, so adim_userre-key left historical rows under the obsoleteuser_fk— the hazardtfact_gradeandtfact_certificatealready guard against with astale_user_fk_lookup. It now reads back rows at or below the watermark whose storeduser_fkno longer matches whatdim_userresolves, re-resolves the key per platform (one equi-join CTE per platform, each pruning to one partition), and unions them in;delete+insertonevent_idreplaces them. Rows above the watermark are excluded so an event the source CTEs re-select cannot also arrive from there and double-insert. It is meant to take effect on ordinary incremental runs, without a full refresh.How can this be tested?
ol-dbt validate: 0 errors. Pre-commit (sqlfluff, yamlfmt, yamllint) passes. CI is green on this branch.tfact_problem_eventsCTEs sit inside{% if is_incremental() %}, and bothdbt parseand sqlfluff renderis_incremental()as false. That branch has not been run against a real target.organization_administration_reportper (email, day, run) in DuckDB, over production dimensional Iceberg tables scoped to MITx Online course runs under a B2B contract. It has not been built on Trino.activity_key. 0 nullcourserun_fk. 8,612 learners, 866 runs.videos_playedequalled the report'svideos_watchedon 79,989 of them (99.95%). Chatbot interactions were at least the report's on all 80,028.showanswer-only days) or email attribution.cast(event_timestamp as date)and DuckDB pinned to UTC. Rather than rebuild the fact after rebucketing ontodate_fk, I checked the two are the same day: across all five event facts over the same B2B-scoped rows (5,420,066 rows),date_fkequals the calendar date at the front ofevent_timestamp_iso8601on every row, with none null. That is the date Trino's cast returns, so the rebucketing leaves production output for this population unchanged.dbt build --select afact_learner_courserun_daily_activity, which runs the uniqueness andnot_nulltests with it.Additional Context
tfact_discussion_events' 17.5M rows carry a local offset (-04:00/-05:00) rather thanZ, and 2.9M of those fall on a different day under UTC. Bucketing ondate_fkkeeps the source-local day the existing report already uses; converting to UTC would have changed production days for those rows.activity_date_keyas a date: joindim_daterather than casting the integer.🤖 Generated with Claude Code
https://claude.ai/code/session_017FmjwANjfgtuKAtsRPf6Ly