Skip to content

feat(#10695): adds interaction tracking service#10786

Open
dianabarsan wants to merge 29 commits into
masterfrom
10695-interaction-log
Open

feat(#10695): adds interaction tracking service#10786
dianabarsan wants to merge 29 commits into
masterfrom
10695-interaction-log

Conversation

@dianabarsan
Copy link
Copy Markdown
Member

@dianabarsan dianabarsan commented Mar 31, 2026

Description

Closes #10695

Adds an opt-in InteractionTrackingService that records user behavior on the tasks tab (list opens/scrolls/leaves, task opens/form submissions/completes/cancels, task-group navigation, task-filter usage) so analysts can study how CHWs work through their queues.

Service (webapp/src/ts/services/interaction-tracking.service.ts)

  • Behind a new can_track_task_interactions permission; no-op for users without it.
  • Events are buffered in memory and persisted in batches to a per-day local PouchDB (interaction-YYYY-M-D-{user}). Buffer flushes on a 50-event threshold, on session end (navigating away from /tasks), and on the page-level visibilitychange listener wired in app.component.
  • Per-day cap of 500 events; further events are dropped silently.
  • On the next init() (typically next app load), any per-day DB that isn't "today" is aggregated into a single type: 'interaction-log' doc in the user's meta DB and the per-day DB is destroyed. The aggregate replicates to the server-side user-meta DB via the normal meta-DB sync.
  • Aggregate _id is interaction-{date}-{user}-{deviceId}, so each (user, day, device) produces one doc — no day-spanning rows and no per-event rows in the meta store.

Tasks integration

  • tasks.component, tasks-content.component, tasks-group.component, tasks-sidebar-filter.component all call into the service for their respective events.
  • task_list:scroll is throttled at 2 s.

Tasks-group permission

  • Also adds can_view_tasks_group and gates the /tasks/group route on it. Bundled here because tracking the task-group flow only makes sense once that route is permission-controlled.

Tests

  • New Karma unit spec for the service (~900 lines) covering init/permission/user-context handling, buffering thresholds, day rollover, the 500-event cap, aggregation of stale per-day DBs, ownership filtering across users, and error tolerance.
  • New e2e spec (tests/e2e/default/tasks/interaction-tracking.wdio-spec.js) drives the full flow under a frozen "yesterday" clock so aggregation can be triggered within a single test. The clock is installed as a Date-only stub via browser.addInitScript (full fake-timers break zone.js scheduling); each test wipes server-side task docs and the
    user's server meta DB before login so the rules engine regenerates tasks and waitForAggregateDoc doesn't pick up a prior run's aggregate.
  • Existing Karma specs for the tasks components updated to mock the new service.

AI Disclosure

I used Claude Code for developing and testing. I have made all design decisions and reviewed code for correctness, clarity and alignment with our standards.

Code review checklist

  • UI/UX backwards compatible: Test it works for the new design (enabled by default). And test it works in the old design, enable can_view_old_navigation permission to see the old design. Test it has appropriate design for RTL languages.
  • Readable: Concise, well named, follows the style guide
  • Documented: Configuration and user documentation on cht-docs
  • Tested: Unit and/or e2e where appropriate
  • Internationalised: All user facing text
  • Backwards compatible: Works with existing data and configuration or includes a migration. Any breaking changes documented in the release notes.
  • AI disclosure: Please disclose use of AI per the guidelines.

Compose URLs

If Build CI hasn't passed, these may 404:

License

The software is provided under AGPL-3.0. Contributions to this project are accepted under the same license.

appliesIf: function () {
return true;
appliesIf: function (contact) {
return contact.contact.role !== 'chw';
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is so a task doesn't get created for the CHW, and instead only for the patients. It saves from needing to explicitly target clicking and completing specific tasks to display the task group page (now any task I click will yield the same result, but if I had a rogue task for the CHW, i would have had to add code to avoid clicking it).

# Conflicts:
#	webapp/src/ts/modules/tasks/tasks.component.ts
#	webapp/tests/karma/ts/modules/tasks/tasks.component.spec.ts
@dianabarsan dianabarsan marked this pull request as ready for review April 14, 2026 11:18
@dianabarsan dianabarsan requested a review from sugat009 April 21, 2026 10:06
@dianabarsan
Copy link
Copy Markdown
Member Author

Hi @sugat009 . I know queues are long and you're probably surprised by this showing up out of the blue. we've been discussing adding a way for tracking user behavior on the task page, to have a framework for evaluating any future changes we might make. this is part of some squad work, and i'm the only dev on the squad. so ... sorry for just dropping this one on you.
i appreciate your time!

@sugat009
Copy link
Copy Markdown
Member

No worries @dianabarsan , I ran out of time today with other PRs. Will look into this first thing tomorrow.

@dianabarsan
Copy link
Copy Markdown
Member Author

No rush @sugat009 . just as long as it makes it by 5.2.0 release :D

Copy link
Copy Markdown
Member

@sugat009 sugat009 left a comment

Choose a reason for hiding this comment

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

A few findings. Most are inline; two below have no specific line to anchor against.

Three of the must fix issues come with failing karma tests I ran locally against the current branch head. The PII narrowing on task.title and the deployment gap on can_track_task_interactions were also verified against the CHT docs and a fresh local instance.


issue (blocking): PR description vs. implemented limit

The PR description says "max 200 sessions/day, max 500 events/session" (~100k events/day implied). The code at interaction-tracking.service.ts:41 is MAX_EVENTS_PER_DAY = 500 total, no session count cap.

Git history:

  1. 7fcb31d09 set MAX_EVENTS_PER_SESSION = 500 (matched the description).
  2. f03e9d318 switched to MAX_EVENTS_PER_DAY = 2000 with an otherEventCount to avoid double counting the current session.
  3. e61eafe15 ("reduce max events to 500") cut to 500 and dropped otherEventCount. Origin of the daily cap halving issue, also where the description drifted.

Combined with the cap halving (see inline comment on interaction-tracking.service.ts:184), the effective ceiling for an active session is ~250. Could you clarify the intended semantics? Options:

  1. 500 per session + session count cap (matches description).
  2. 500 per day total (needs the cap halving fix + description update).
  3. Higher per day + description update + cap halving fix.

chore (non-blocking): Unrelated refactors in the diff

Four bits of work with no functional link to interaction tracking:

  1. webapp/src/ts/app.component.ts: privateprivate readonly on 30+ constructor params.
  2. tests/utils/sentinel.js + tests/integration/sentinel/schedules/purging.spec.js: waitForPurgeCompletion now returns the purge log directly.
  3. tests/utils/index.js: stringifyParam extracted from getRequestUri.
  4. tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js: appliesIf tightened.

Each is fine on its own; together they inflate the review surface. For future PRs of this size, consider splitting precursor refactors into their own PRs.

Comment thread webapp/src/ts/services/interaction-tracking.service.ts Outdated
Comment thread webapp/src/ts/services/interaction-tracking.service.ts Outdated
Comment thread sentinel/src/schedule/replications.js
Comment thread webapp/src/ts/services/interaction-tracking.service.ts Outdated
Comment thread webapp/src/ts/services/interaction-tracking.service.ts
Comment thread webapp/src/ts/services/interaction-tracking.service.ts Outdated
Comment thread webapp/tests/karma/ts/modules/tasks/tasks.component.spec.ts Outdated
Comment thread webapp/tests/karma/ts/services/interaction-tracking.service.spec.ts Outdated
Comment thread tests/e2e/default/tasks/interaction-tracking.wdio-spec.js Outdated
Comment thread tests/e2e/default/tasks/interaction-tracking.wdio-spec.js Outdated
# Conflicts:
#	webapp/tests/karma/ts/modules/tasks/tasks-group.component.spec.ts
#	webapp/tests/karma/ts/modules/tasks/tasks.component.spec.ts
… task permissions, and enhance session event validation
@dianabarsan
Copy link
Copy Markdown
Member Author

Addresses the 2026-04-27 review. Most of the blocking issues shared a root
cause in the old rolling-doc + periodic-save design; rewrote that core
instead of patching each symptom.

Architecture rewrite (covers blocking #2, #3, #5, #7)

Events are buffered in memory and persisted in batches to a per-day local
PouchDB (interaction-YYYY-M-D-{user}). On each app init(), any per-day
DB that isn't "today" is aggregated into a single interaction-log doc in
the user's meta DB and the source DB is destroyed.

  • No periodic save() timer → no cap-halving double-count.
  • No shared rolling doc → no _persist race; writes are append-only
    bulkDocs to per-day DBs, aggregation is serialized by an
    aggregationInFlight promise guard.
  • Day-rollover handled in record() via rolloverAndCheckCapacity, which
    flushes the buffer and resets persistedEventCount — fixes the day N+1
    block.
  • getServiceWorker() is called once per stale per-day DB during init,
    not on every persist. Down from ~96/shift to 0–1.
  • this.user is captured in init(); aggregate _id includes
    (date, user, deviceId). No user-context drift between record and
    persist.
  • Aggregate is only written when init() finds a real userCtx().name,
    so metadata.user never falls back to 'unknown'.

Direct fixes from the review

  • Registered can_track_task_interactions in
    config/default/app_settings.json (Show results on a per project basis #6).
  • Added interaction- to webapp/src/js/bootstrapper/purger.js prefix
    filter so IDB cleans up on the webapp side too (problems with utf-8 character encodings #4).
  • Removed the unreliable beforeunload flush; only visibilitychange
    triggers persistBuffer().
  • Chained interactionTrackingService.init() into setupPromise before
    initRulesEngine, so by the time /tasks is reachable, init has
    resolved.
  • e2e spec rewritten: browser.pause replaced with waitUntil helpers;
    getInteractionMetaDocs returns rows only, assertions live in the
    tests.

Tests

  • All four task component specs (tasks, tasks-content, tasks-group,
    sidebar-filter) pin the action keys passed to record(...). A rename
    like task_list:opentask_list:opened now breaks the spec.
  • Karma spec for the service covers init / permission / user-context
    gating, dedup, day rollover, 500-event cap, aggregation of stale
    per-day DBs, ownership filtering across users, and error tolerance.
  • e2e spec uses a Date-only stub injected via browser.addInitScript,
    not browser.emulate('clock'). The latter pulls in @sinonjs/fake-timers,
    which (a) calls require('util').promisify while webpack's process
    polyfill makes the guard truthy → ReferenceError, and (b) overrides
    setTimeout/setInterval globally, which floods zone.js with
    "executing a cancelled action" from RxJS. Date.now() is monotonic
    via a per-call tick so events have unique, chronologically ordered
    timestamps.

Deferred

  • PII on task.title in tasks-content / tasks-group records.
    Because multiple tasks can have the same form-as-action, the only way to disambiguate the actual task is by using the unique task title.

@dianabarsan dianabarsan requested a review from sugat009 May 15, 2026 07:53
@dianabarsan
Copy link
Copy Markdown
Member Author

@sugat009 slight nudge here. we might go through some rounds before this is ready, and I really want to ship this is 5.2. appreciate it!

@sugat009
Copy link
Copy Markdown
Member

@sugat009 slight nudge here. we might go through some rounds before this is ready, and I really want to ship this is 5.2. appreciate it!

Oh, I missed this one. Let me take a look.

Copy link
Copy Markdown
Member

@sugat009 sugat009 left a comment

Choose a reason for hiding this comment

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

The architecture rewrite cleanly addresses the 2026-04-27 blockers. CI green, all 47 checks pass.

I also ran this end to end on a local dev instance: rebuilt the bundle, logged in as a CHW user, drove the tasks tab, and inspected the per day IndexedDB and the meta DB. Service initializes, events buffer and persist, aggregate docs land in meta DB. Spot on.

Four inline comments below. None individually block the merge.

.then(() => this.checkPrivacyPolicy())
.then(() => (this.initialisationComplete = true))
.then(() => this.initUser())
.then(() => this.interactionTrackingService.init())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

issue (non-blocking): init() rejection aborts app boot via the global catch at line 324, navigating to /error/503.

The spec at webapp/tests/karma/ts/services/interaction-tracking.service.spec.ts:204 documents the contract: "The error propagates to the caller (so app.component can decide what to do), but the service is left in a safe disabled state." The service already lands safely on internal failure; the call site just needs to honor that:

.then(() => this.interactionTrackingService.init().catch(err => {
  console.warn('Interaction tracking disabled', err);
}))

}

const taskIndex = this.tasksList.indexOf(task);
this.interactionTrackingService.record('task:open', task.title, String(taskIndex));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

issue (non-blocking): task.title is template resolved by rules-engine.service.ts:379, so {{contact.name}} interpolation reaches this record() call. You documented exactly this risk at tasks-sidebar-filter.component.ts:155-157 and stripped it from the filter path, but kept it here per your "Deferred" note in the response above.

Tested locally: an older aggregate in a CHW user's meta DB had ref: "Home visit for {{contact.name}}" recorded literally. In deployments with real contact data, that resolves to the patient name.

suggestion:

  1. Open a tracking issue and add // TODO(#xxxxx) at both call sites (here and tasks-group.component.ts:253) so the deferral survives the squash merge.
  2. Either expand the filter comment to acknowledge the asymmetry is intentional, or add a one liner here explaining that the title is used for instance disambiguation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I addressed this in the comment:

PII on task.title in tasks-content / tasks-group records.
Because multiple tasks can have the same form-as-action, the only way to disambiguate the > actual task is by using the unique task title.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixing with storing the title key separately, and logging the untranslated version.

};
}

private aggregateDocId(date: string): string {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

question: CHT has two existing patterns for date based meta DB doc IDs.

  • Telemetry (telemetry.service.ts:49-58, :85-97): unpadded year/month/day in _id, plus numeric year/month/day on metadata. The dbt models on the CHT Sync Postgres replica filter on doc#>>'{metadata,year}' and doc#>>'{metadata,month}'.
  • Feedback (feedback.service.ts:197-201): feedback-${new Date().toISOString()}-${uuid}, sortable on its own.

The aggregate _id here uses telemetry's unpadded shape, but the metadata at lines 388 to 394 only carries the string date — no numeric year/month/day. So this doc is queryable by neither path: _id range scans put October before February, and the dbt pattern that telemetry supports needs metadata.month to exist.

While testing locally I noticed the meta DB already holds aggregates from earlier iterations with interaction-2026-04-24-… (padded) alongside what current code produces (unpadded). Mixed formats already in the same namespace.

Was the telemetry style _id deliberate? If yes, would you mirror its numeric metadata.year/month/day so existing dbt models work the same? If keeping metadata minimal matters, would feedback's ISO 8601 _id be simpler?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think the unpadded version was a miss. thanks for flagging it.

Comment thread webapp/src/ts/services/integration-api.service.ts
…proper zero-padded date formats, and enhance error handling during initialization

Signed-off-by: Diana Barsan <barsan@medic.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement telemetry to track task selection duration

2 participants