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
131 changes: 131 additions & 0 deletions cd2_views/create_user_activity_view_canvas.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
-- user_activity view — canvas database (primary Canvas instance)
--
-- In CD2, everything is identified by user_id (as in, Canvas ID), so there will be
-- unique activity per user_id. We can't assume that a single user_id will map to
-- a single integration_id (PUID) or sis_user_id (student number), however.
-- We have not 'squished' any of these into single lines, so, e.g., if one user_id
-- maps to two integration_ids, there will be two rows with the same activity.
-- In other words, there will be one row per unique user_id x integration_id
-- x sis_user_id (x workflow_state) combination.
--
-- Almost every user_id has two pseudonyms entries, one with an integration_id etc.,
-- and one that is quite empty. We have filtered out any pseudonyms with no integration_id,
-- as these lines will be useless when only searching on integration_id.
-- But *all* non-null integration_ids have been kept in, even those with atypical
-- formats, such as 12345ABCD_INC12345.
--
-- Note there's also no filtering here based on the workflow_state of the entry in
-- pseudonyms or users, though both can be, e.g., 'deleted'.
--
-- From Claude:
-- Run against the "canvas" CD2 database, connected as the CD2 database owner
-- (the db user created for this database in setup/prepare_aurora_db.py), so
-- pg-deps-management's view-dependency hooks pick up the view as owned by a
-- role sync_table already knows how to drop/recreate on schema change.

CREATE OR REPLACE VIEW canvas.user_activity AS

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A common convention is to name views with something that indicates that it's a view since the consumer may not know they're working against a view.

Craig has used _vw suffix on his views. Let's name this canvas.user_activity_vw

-- I don't actually expect much, if any, aggregation to happen here,
-- as I haven't seen multiple pseudonyms with the same user_id, integration_id,
-- sis_user_id, AND workflow_state. The point of this subquery is to
-- pair up user_ids (which will have all the associated CD2 activity)
-- with any/all integration_ids linked (the same is happening with sis_user_ids
-- and workflow_states, but those are not the main goal).
WITH pseudonym_agg AS (
SELECT
user_id,
integration_id,
sis_user_id,
workflow_state,
MAX(last_login_at) AS last_login_at,
MAX(last_request_at) AS last_request_at,
Comment on lines +39 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use a GREATEST of last_login_at and current_login_at and drop the last_request_at? I don't think the request timestamp adds much for their purposes. Login will be enough.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally fair, but I think we could just use current_login_at in that case since:

  • last_login_at will always be prior to current_login_at
  • there's no instances of having a null current_login_at but a non-null last_login_at

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good

SUM(login_count) AS login_count
FROM canvas.pseudonyms
WHERE integration_id IS NOT NULL
GROUP BY user_id, integration_id, sis_user_id, workflow_state
),
enrollment_agg AS (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about including SUM(total_activity_time) for enrollments? I'm not sure how accurate it is, but it adds another dimension that they can use when reviewing what accounts are being used?

SELECT
user_id,
COUNT(*) AS enrolment_count_total,
-- active enrolments != classes currently being taken
-- I believe it's all enrolments, past and present, that they student didn't drop
-- or otherwise get removed from. I believe completed courses are active.
COUNT(*) FILTER (WHERE workflow_state = 'active') AS enrolment_count_active
FROM canvas.enrollments
GROUP BY user_id
),
submission_agg AS (
SELECT
user_id,
COUNT(*) AS submission_count,
MAX(submitted_at) AS last_submission_at
FROM canvas.submissions
WHERE submitted_at IS NOT NULL
AND workflow_state IN ('submitted', 'graded')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

include pending_review for submissions that haven't been graded yet

GROUP BY user_id
),

-- Note that quiz_submissions is basically a subset of submissions:
-- all quiz_submission entries should have a corresponding entry in submissions.
-- I say 'basically' because I've seen different created_at dates for
-- the 'same' entry -- probably they are created, empty, before the student
-- actually does anything, and those times are not necessarily the same,
-- meaning one *can* exist without the other.
Comment on lines +67 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got an error when trying to run the query with this comment here. Move the comment inside the table expression.

quiz_submission_agg AS (
SELECT
user_id,
COUNT(*) AS quiz_submission_count,
MAX(finished_at) AS last_quiz_submission_at
FROM canvas.quiz_submissions
WHERE workflow_state = 'complete'
GROUP BY user_id
),
discussion_entry_agg AS (
SELECT
user_id,
COUNT(*) AS discussion_entry_count,
MAX(created_at) AS last_discussion_entry_at
FROM canvas.discussion_entries
WHERE workflow_state = 'active'
GROUP BY user_id
),
conversation_message_agg AS (
SELECT
author_id AS user_id,
COUNT(*) AS conversation_message_count,
MAX(created_at) AS last_conversation_message_at
FROM canvas.conversation_messages

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe take a look at what messages look like when they're system generated? eg conversation_messages.generated - might want to filter these out?

-- messages don't have workflow_states
GROUP BY author_id
)
SELECT
pa.integration_id AS sis_integration_id,
pa.sis_user_id,
pa.user_id,
pa.workflow_state AS pseudonym_workflow_state,
u.merged_into_user_id,
pa.last_login_at,
pa.last_request_at,
COALESCE(pa.login_count, 0) AS login_count,
COALESCE(ea.enrolment_count_active, 0) AS enrolment_count_active,
COALESCE(ea.enrolment_count_total, 0) AS enrolment_count_total,
COALESCE(sa.submission_count, 0) AS submission_count,
sa.last_submission_at,
COALESCE(qa.quiz_submission_count, 0) AS quiz_submission_count,
qa.last_quiz_submission_at,
COALESCE(da.discussion_entry_count, 0) AS discussion_entry_count,
da.last_discussion_entry_at,
COALESCE(ca.conversation_message_count, 0) AS conversation_message_count,
ca.last_conversation_message_at
FROM pseudonym_agg pa
LEFT JOIN canvas.users u ON u.id = pa.user_id
LEFT JOIN enrollment_agg ea ON ea.user_id = pa.user_id
LEFT JOIN submission_agg sa ON sa.user_id = pa.user_id
LEFT JOIN quiz_submission_agg qa ON qa.user_id = pa.user_id
LEFT JOIN discussion_entry_agg da ON da.user_id = pa.user_id
LEFT JOIN conversation_message_agg ca ON ca.user_id = pa.user_id;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

; is one line too early

ORDER BY sis_integration_id

-- If running against catalog instead, change athena to athena_catalog
GRANT SELECT ON canvas.user_activity TO athena;
GRANT SELECT ON canvas.user_activity TO ubc_it_easd_cache
20 changes: 20 additions & 0 deletions cd2_views/create_user_activity_view_canvas.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Hi EASD folks,

The view has been created. Here are a couple notes on what the data covers:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what we should provide to them is a copy of the view DDL plus a detailed description. I asked Claude for a draft. I'd interleave your observations/experience into something like this.

Data dictionary — canvas.user_activity

Grain. One row per distinct (user_id, sis_integration_id, sis_user_id, pseudonym_workflow_state) combination. Activity metrics are computed per user_id and then repeated on every row that user has — if one user_id carries two integration_ids, both rows show identical activity. Never SUM metrics across rows; aggregate per user_id first. Neither direction is 1:1: one user_id can have multiple integration_ids, and one integration_id can appear under multiple user_ids (un-merged duplicate accounts).

Coverage. Only users with at least one pseudonym (login record) carrying a non-null integration_id appear. Users whose only pseudonyms lack an integration_id — and some users with no pseudonym row at all, which CD2 documents as possible — are absent entirely. No filtering on deleted/suspended states: deleted logins and deleted users are included.

Freshness. This is a replica: Instructure's CD2 feed (typically up to ~4 h behind live Canvas) synced into this database every 3 hours. Treat values as "as of a few hours ago," never real-time. Upstream deletions are hard deletes — rows can disappear between reads.

Scope of counts. All counts are lifetime totals across the whole Canvas history — no term or date scoping. All timestamps are UTC (timestamptz).

Identity columns

sis_integration_id — varchar(255)

pseudonyms.integration_id; the PUID. All non-null values are kept, including atypical formats (e.g. 12345ABCD_INC12345) — don't assume a fixed pattern.

sis_user_id — varchar(255), nullable

pseudonyms.sis_user_id; the student number. Can be NULL even when integration_id is present.

user_id — bigint

users.id, the Canvas internal ID; the key all activity is aggregated on. Oddity: IDs > 10,000,000,000,000 denote users from other Canvas instances (trust/consortium); they can appear here if they hold a local pseudonym.

pseudonym_workflow_state — enum: active, deleted, suspended

Lifecycle of the login record, not the user. Included in the row key, so a state change shows up as one row disappearing and another appearing.

merged_into_user_id — bigint, nullable

From users; set when this user account was merged into another. Activity does not follow the merge in this view — treat rows with this set as aliases pointing at the surviving user_id, whose own row carries the ongoing activity.

Login columns

Source: pseudonyms, aggregated over the row's matching login records.

last_login_at — timestamptz, nullable · [revision pending]

MAX of pseudonyms.last_login_at. Known oddity: Canvas stores the previous login here; the most recent login is current_login_at, which this view doesn't currently expose. Expect this to understate recency by one login.

last_request_at — timestamptz, nullable

MAX of pseudonyms.last_request_at; the best "last seen" signal — any authenticated page/API request, not just logins. Canvas throttles updates (roughly once per 10 min), so it's approximate. Activity via mobile apps or API tokens may not always register against the pseudonym.

login_count — bigint, 0 if none

SUM of pseudonyms.login_count across the row's matching login records. Counts session logins (including each SSO sign-in); not page views.

Enrolment columns

Source: enrollments.

enrolment_count_active — bigint

Count of enrollment records with workflow_state = 'active'. Three oddities: (1) these are enrollment records, not courses — enrollments are unique per course × section × role, so one student in a 3-section course contributes 3; (2) all role types count (Teacher, TA, Designer, Observer, Student); (3) "active" includes past terms — enrollments usually stay active after a term ends unless explicitly concluded (completed).

enrolment_count_total — bigint

All enrollment records in any state, including deleted, rejected, invited, inactive, completed, creation_pending.

Assignment submission columns

Source: submissions.

submission_count — bigint · [revision pending]

Submissions with a non-null submitted_at and state submitted or graded. Oddities: submissions holds one row per user × assignment, so this is "assignments with a submission," not submission acts (resubmissions don't increment it). Excludes pending_review (submitted, awaiting manual grading). Undercounts paper/external-tool assignments, where Canvas legitimately leaves submitted_at NULL even when graded. Includes New Quizzes (they flow through submissions, not quiz_submissions) and graded discussions.

last_submission_at — timestamptz, nullable

MAX submitted_at over those rows; resubmitting updates it to the latest attempt.

Quiz columns

Source: quiz_submissionsclassic quizzes only.

quiz_submission_count — bigint · [revision pending]

Quiz submissions with state complete. Oddities: the table keeps one row per user × quiz (latest attempt), so this is "distinct classic quizzes completed," not attempts. Excludes pending_review (submitted, essay questions awaiting grading). New Quizzes are absent from this table entirely — they appear under submission_count instead, so don't compare these two columns as if disjoint or complete.

last_quiz_submission_at — timestamptz, nullable

MAX finished_at of completed classic quiz submissions.

Discussion columns

Source: discussion_entries.

discussion_entry_count — bigint

Non-deleted (active) discussion replies. The initial post of a topic lives in discussion_topics and is not counted. Replies on graded discussions also produce a submissions row, so that activity is visible in both columns.

last_discussion_entry_at — timestamptz, nullable

MAX created_at of those replies; later edits don't move it.

Inbox columns

Source: conversation_messages.

conversation_message_count — bigint · [revision pending]

Messages authored in the Canvas Inbox. Known oddity: includes system-generated rows ("X was added to the conversation") because the generated flag isn't filtered yet — expect modest inflation for some users. Messages persist here even if participants deleted them from their inboxes.

last_conversation_message_at — timestamptz, nullable

MAX created_at of authored messages, system-generated included.

- This view has one row per unique sis_integration_id (PUID) x sis_user_id (student number) x user_id (Canvas user ID) x pseudonyms_workflow_state combination. The latter is the status of the entry in our CD2 pseudonyms table.
- As we anticipate your searching to be by PUID, we have filtered to only rows with non-null PUIDs. All other fields may be null.
- The view name is user_activity, and the role name is ubc_it_easd_cache.
- The stats presented per user cover:
- logins and requests
- enrolments
- Note that 'active' enrolments are not just the student's enrolments for the current semester, but generally represent all enrolments, past and present, where the student did not drop the course or was otherwise removed.
- submissions
- This covers assignments, quizzes, etc.
- quiz_submissions
- This should be a subset of the above. This may not cover everything that we might think of as quizzes, as Canvas has 'Classic' and 'New' quizzes and typically only counts the former as quizzes. But all of these will be included in submissions above.
- discussion_entries
- conversation_messages
- This covers direct messages sent via Canvas's messaging system, which are often announcements sent from the instructor to all students.

Don't hesitate to reach out if there's anything else I can clarify about the data! Best,
Anna