Normalizing LMS Login Frequency Across Platforms into a Weekly Cadence
Login frequency is the single most portable engagement signal — every learner logs in, on every platform — yet it is metered three incompatible ways. Canvas timestamps each page view; Moodle records \core\event\user_loggedin rows with a Unix epoch; Blackboard reports coarser course-access events. This guide harmonizes all three into one comparable metric: weekly active days, the count of distinct calendar days a learner touched the platform in an ISO week. That single cadence number behaves identically whether a learner lives in Canvas, Moodle, or Blackboard, which is exactly what the cross-vendor case demands. It is the harder sibling of Computing Canvas Page-View Engagement Scores, and an instance of the Engagement Metric Normalization guide under Engagement Analytics & Reporting Automation.
Prerequisites
Confirm each of these before running the script — it assumes all of them.
Step-by-step implementation
1. Give each platform its own adapter, not one shared parser. Canvas, Moodle, and Blackboard disagree on identifier field, timestamp encoding, and event shape, so a per-vendor adapter that emits a common record is the only maintainable structure — adding a fourth platform is a new adapter, not a rewrite.
2. Parse every timestamp to timezone-aware UTC. Moodle’s Unix epoch and Canvas’s ISO string must land on the same axis before any day can be counted, and normalizing to UTC avoids a login near midnight being assigned to different days by different institutions’ local zones.
3. Tokenize the learner id inside each adapter, before records converge. Each vendor’s raw id is hashed to a shared stu_… token so the unified stream is de-identified from the first moment, following FERPA-Compliant PII Handling. Using one salt across platforms is what lets the same person’s Canvas and Moodle logins land under one token.
4. Collapse many logins per day into distinct active days. A learner who logs in eight times on Monday is as “active that day” as one who logs in once; counting raw logins would reward frantic clicking over steady engagement, so de-duplicate to (learner_token, calendar_day) first.
5. Count distinct days per ISO week into the cadence metric. Group the de-duplicated days by (learner_token, iso_week) and count them, yielding a 0–7 weekly active-days figure that is directly comparable across all three platforms without any cohort baselining — the comparability comes from the shared unit, per Engagement Metric Normalization.
Complete runnable code block
import hashlib
import logging
import os
from dataclasses import dataclass
from datetime import datetime, timezone
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("login_cadence")
def tokenize(raw_id: str, salt: str) -> str:
"""Shared salted SHA-256 token so one learner maps identically across platforms."""
digest = hashlib.sha256(f"{salt}:{raw_id}".encode("utf-8")).hexdigest()
return f"stu_{digest[:32]}"
@dataclass
class LoginEvent:
learner_token: str
platform: str
login_at: datetime # timezone-aware UTC
def _canvas_adapter(records: list[dict], salt: str) -> list[LoginEvent]:
"""Canvas: ISO 8601 created_at, learner in 'user_id'."""
out = []
for r in records:
ts = datetime.fromisoformat(str(r["created_at"]).replace("Z", "+00:00"))
out.append(LoginEvent(tokenize(str(r["user_id"]), salt), "canvas",
ts.astimezone(timezone.utc)))
return out
def _moodle_adapter(records: list[dict], salt: str) -> list[LoginEvent]:
"""Moodle: Unix epoch timecreated (seconds), learner in 'userid'."""
out = []
for r in records:
ts = datetime.fromtimestamp(int(r["timecreated"]), tz=timezone.utc)
out.append(LoginEvent(tokenize(str(r["userid"]), salt), "moodle", ts))
return out
def _blackboard_adapter(records: list[dict], salt: str) -> list[LoginEvent]:
"""Blackboard: ISO 8601 access timestamp, learner in 'userName'."""
out = []
for r in records:
ts = datetime.fromisoformat(str(r["created"]).replace("Z", "+00:00"))
out.append(LoginEvent(tokenize(str(r["userName"]), salt), "blackboard",
ts.astimezone(timezone.utc)))
return out
ADAPTERS = {
"canvas": _canvas_adapter,
"moodle": _moodle_adapter,
"blackboard": _blackboard_adapter,
}
def weekly_active_days(sources: dict[str, list[dict]], salt: str) -> pd.DataFrame:
"""Harmonize per-platform login records into a weekly active-days cadence."""
events: list[LoginEvent] = []
for platform, records in sources.items():
adapter = ADAPTERS.get(platform)
if adapter is None:
raise ValueError(f"no adapter for platform {platform!r}")
events.extend(adapter(records, salt))
if not events:
return pd.DataFrame(columns=["learner_token", "iso_week", "active_days"])
df = pd.DataFrame([e.__dict__ for e in events])
# 4 · Collapse to one row per learner per calendar day (dedupe frantic logins).
df["calendar_day"] = df["login_at"].dt.date
df["iso_week"] = df["login_at"].dt.strftime("%G-W%V") # ISO year + week
distinct_days = df.drop_duplicates(subset=["learner_token", "calendar_day"])
# 5 · Count distinct days per ISO week -> 0..7 cadence.
cadence = (
distinct_days.groupby(["learner_token", "iso_week"], as_index=False)["calendar_day"]
.count()
.rename(columns={"calendar_day": "active_days"})
)
logger.info("Normalized %d logins -> %d learner-week cadence rows",
len(df), len(cadence))
return cadence.sort_values(["iso_week", "active_days"], ascending=[True, False])
if __name__ == "__main__":
salt = os.environ["ENGAGEMENT_SALT"]
demo = {
"canvas": [
{"user_id": 4471, "created_at": "2026-02-16T09:12:00Z"},
{"user_id": 4471, "created_at": "2026-02-16T18:40:00Z"}, # same day -> 1
{"user_id": 4471, "created_at": "2026-02-18T08:05:00Z"},
],
"moodle": [{"userid": 90233, "timecreated": 1739700000}],
"blackboard": [{"userName": "jdoe", "created": "2026-02-19T14:00:00Z"}],
}
print(weekly_active_days(demo, salt).to_string(index=False))
Verification and output validation
- Cadence is bounded 0–7.
assert frame["active_days"].between(0, 7).all()— a value above 7 means the day-level de-duplication was skipped and raw logins leaked through. - Same-day logins collapse. In the demo, the learner’s two Canvas logins on 2026-02-16 count as one day; assert the Canvas learner’s
active_daysreflects distinct days, not raw login count. - Tokenized and joinable.
assert frame["learner_token"].str.startswith("stu_").all(), and a learner present on two platforms under the same real id shares one token — verify by feeding the same id through two adapters and checking the tokens match. - ISO week is stable across year boundaries. A login on 2025-12-31 and one on 2026-01-01 may fall in the same ISO week;
strftime("%G-W%V")uses the ISO year, so2026-W01is not confused with2025-W01. - No timezone drift. All
login_atvalues are UTC-aware; assertdf["login_at"].dt.tz is not Nonebefore counting days, or a naive timestamp will shift a midnight login into the wrong day.
Troubleshooting
ValueError: Invalid isoformat stringon Moodle records. You routed Moodle’s integertimecreatedthrough the ISO parser. Moodle epochs must go throughdatetime.fromtimestamp; confirm each source dict reached the adapter registered for its platform.- A learner’s cadence exceeds 7. Day-level de-duplication did not run, so multiple same-day logins were each counted. Ensure
drop_duplicates(subset=["learner_token", "calendar_day"])executes before the weeklygroupby. - The same person appears as two tokens. The platforms keyed the learner differently (Canvas
user_idvs. BlackboarduserName) and no cross-platform identity resolution ran. Resolve identities first with resolving duplicate student ids across LMS platforms, then tokenize the resolved canonical id. - Weeks look off by one near New Year. You used
%Y-W%Uor%Winstead of ISO week numbering. Use%G-W%Vso the ISO year and ISO week stay aligned across the boundary. - Blackboard cadence undercounts real activity. Course-access events are coarser than Canvas page views, so a Blackboard learner can look less active for identical behaviour. Treat active-days as a floor for Blackboard and note the resolution caveat from Engagement Metric Normalization; do not compare raw counts, only cadence.
KeyErroron a vendor field. A record was missing its expected id or timestamp key — often a malformed export row. Validate each source list against its expected shape at the ingestion boundary before it reaches the normalizer.
Related
- Engagement Metric Normalization — the parent guide defining the entity model and comparability discipline this normalizer serves.
- Computing Canvas Page-View Engagement Scores — the single-vendor sibling that scores intensity where this one measures cadence.
- Resolving Duplicate Student IDs Across LMS Platforms — mapping one learner to one canonical id before tokenizing across platforms.
- Building an At-Risk Student Flag in Python — how a falling weekly cadence becomes an early-warning input.
Part of: Engagement Metric Normalization