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.

Cross-platform login-frequency normalization Three vendor login sources on the left — Canvas ISO 8601 timestamps, Moodle Unix epoch timecreated, and Blackboard course-access events — each feed a per-vendor adapter that parses to a UTC timestamp and tokenizes the learner id. The three adapters converge on a unified login-event stream, which is de-duplicated to distinct calendar days per learner-week and counted into a weekly active-days cadence metric. Canvas logins ISO 8601 created_at Moodle logins Unix epoch timecreated Blackboard access course-access events Per-vendor adapter parse → UTC · tokenize id (one per platform) Unified login stream learner_token · platform login_at (UTC) · iso_week De-duplicate to distinct days collapse many logins/day → 1 Weekly active-days cadence 0–7 distinct days per learner-week comparable across all three platforms

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

python
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_days reflects 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, so 2026-W01 is not confused with 2025-W01.
  • No timezone drift. All login_at values are UTC-aware; assert df["login_at"].dt.tz is not None before counting days, or a naive timestamp will shift a midnight login into the wrong day.

Troubleshooting

  • ValueError: Invalid isoformat string on Moodle records. You routed Moodle’s integer timecreated through the ISO parser. Moodle epochs must go through datetime.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 weekly groupby.
  • The same person appears as two tokens. The platforms keyed the learner differently (Canvas user_id vs. Blackboard userName) 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%U or %W instead of ISO week numbering. Use %G-W%V so 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.
  • KeyError on 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.

Part of: Engagement Metric Normalization