core/templates/pages/exploration-player-page/services/stats-reporting.service.ts

Summary

Maintainability
D
2 days
Test Coverage
// Copyright 2021 The Oppia Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @fileoverview Services for stats reporting.
 */

import {downgradeInjectable} from '@angular/upgrade/static';
import {Injectable, NgZone} from '@angular/core';

import {ContextService} from 'services/context.service';
import {UrlService} from 'services/contextual/url.service';
import {MessengerService} from 'services/messenger.service';
import {PlaythroughService} from 'services/playthrough.service';
import {SiteAnalyticsService} from 'services/site-analytics.service';
import {
  AggregatedStats,
  StatsReportingBackendApiService,
} from 'domain/exploration/stats-reporting-backend-api.service';
import {Stopwatch} from 'domain/utilities/stopwatch.model';
import {ServicesConstants} from 'services/services.constants';

@Injectable({
  providedIn: 'root',
})
export class StatsReportingService {
  constructor(
    private contextService: ContextService,
    private messengerService: MessengerService,
    private playthroughService: PlaythroughService,
    private siteAnalyticsService: SiteAnalyticsService,
    private statsReportingBackendApiService: StatsReportingBackendApiService,
    private urlService: UrlService,
    private ngZone: NgZone
  ) {
    this.editorPreviewMode = this.contextService.isInExplorationEditorPage();
    this.questionPlayerMode = this.contextService.isInQuestionPlayerMode();
    this.refreshAggregatedStats();
  }

  // These properties are initialized using Angular lifecycle hooks
  // and we need to do non-null assertion. For more information, see
  // https://github.com/oppia/oppia/wiki/Guide-on-defining-types#ts-7-1
  explorationId!: string;
  explorationTitle!: string;
  explorationVersion!: number;
  sessionId!: string;
  stateStopwatch!: Stopwatch;
  optionalCollectionId!: string;
  currentStateName!: string;
  nextExpId!: string;
  previousStateName!: string;
  nextStateName!: string;
  topicName!: string;
  private editorPreviewMode: boolean = false;
  private questionPlayerMode: boolean = false;
  statesVisited: Set<string> = new Set();
  explorationStarted: boolean = false;
  explorationActuallyStarted: boolean = false;
  explorationIsComplete: boolean = false;
  private MINIMUM_NUMBER_OF_VISITED_STATES = 3;

  // The following dict will contain all stats data accumulated over the
  // interval time and will be reset when the dict is sent to backend for
  // recording.
  aggregatedStats!: AggregatedStats;

  private refreshAggregatedStats(): void {
    this.aggregatedStats = {
      num_starts: 0,
      num_completions: 0,
      num_actual_starts: 0,
      state_stats_mapping: {},
    };
  }

  private createDefaultStateStatsMappingIfMissing(stateName: string): void {
    if (this.aggregatedStats.state_stats_mapping.hasOwnProperty(stateName)) {
      return;
    }
    this.aggregatedStats.state_stats_mapping[stateName] = {
      total_answers_count: 0,
      useful_feedback_count: 0,
      total_hit_count: 0,
      first_hit_count: 0,
      num_times_solution_viewed: 0,
      num_completions: 0,
    };
  }

  private startStatsTimer(): void {
    if (!this.editorPreviewMode && !this.questionPlayerMode) {
      this.ngZone.runOutsideAngular(() => {
        setInterval(() => {
          this.ngZone.run(() => {
            this.postStatsToBackend();
          });
        }, 300000);
      });
    }
  }

  // This method is called whenever a learner tries to leave an exploration,
  // when a learner starts an exploration, when a learner completes an
  // exploration and also every five minutes.
  private postStatsToBackend(): void {
    if (this.explorationIsComplete) {
      return;
    }

    this.statsReportingBackendApiService
      .postsStatsAsync(
        this.aggregatedStats,
        this.explorationVersion,
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });

    this.refreshAggregatedStats();
  }

  setTopicName(newTopicName: string): void {
    this.topicName = newTopicName;
  }

  initSession(
    newExplorationId: string,
    newExplorationTitle: string,
    newExplorationVersion: number,
    newSessionId: string,
    collectionId: string
  ): void {
    this.explorationId = newExplorationId;
    this.explorationTitle = newExplorationTitle;
    this.explorationVersion = newExplorationVersion;
    this.sessionId = newSessionId;
    this.stateStopwatch = Stopwatch.create();
    this.optionalCollectionId = collectionId;
    this.refreshAggregatedStats();
    this.startStatsTimer();
  }

  // Note that this also resets the stateStopwatch.
  // The type of params is declared as Object since it can vary depending
  // on the stateName.
  recordExplorationStarted(stateName: string, params: Object): void {
    if (this.explorationStarted) {
      return;
    }
    this.aggregatedStats.num_starts += 1;

    this.createDefaultStateStatsMappingIfMissing(stateName);
    this.aggregatedStats.state_stats_mapping[stateName].total_hit_count += 1;
    this.aggregatedStats.state_stats_mapping[stateName].first_hit_count += 1;

    this.postStatsToBackend();

    this.currentStateName = stateName;

    this.statsReportingBackendApiService
      .recordExpStartedAsync(
        params,
        this.sessionId,
        stateName,
        this.explorationVersion,
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });

    this.statsReportingBackendApiService
      .recordStateHitAsync(
        0.0,
        this.explorationVersion,
        stateName,
        params,
        this.sessionId,
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });

    this.messengerService.sendMessage(
      ServicesConstants.MESSENGER_PAYLOAD.EXPLORATION_LOADED,
      {
        explorationVersion: this.explorationVersion,
        explorationTitle: this.explorationTitle,
      }
    );

    this.statesVisited.add(stateName);
    this.siteAnalyticsService.registerNewCard(1, this.explorationId);

    this.stateStopwatch.reset();
    this.explorationStarted = true;
    this.siteAnalyticsService.registerStartExploration(this.explorationId);
  }

  recordExplorationActuallyStarted(stateName: string): void {
    if (this.explorationActuallyStarted) {
      return;
    }
    this.aggregatedStats.num_actual_starts += 1;
    this.currentStateName = stateName;

    this.statsReportingBackendApiService
      .recordExplorationActuallyStartedAsync(
        this.explorationVersion,
        stateName,
        this.sessionId,
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });

    this.playthroughService.recordExplorationStartAction(stateName);
    this.explorationActuallyStarted = true;
  }

  recordSolutionHit(stateName: string): void {
    this.createDefaultStateStatsMappingIfMissing(stateName);
    this.aggregatedStats.state_stats_mapping[
      stateName
    ].num_times_solution_viewed += 1;
    this.currentStateName = stateName;

    this.statsReportingBackendApiService
      .recordSolutionHitAsync(
        this.stateStopwatch.getTimeInSecs(),
        this.explorationVersion,
        stateName,
        this.sessionId,
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });
  }

  recordLeaveForRefresherExp(stateName: string, refresherExpId: string): void {
    this.currentStateName = stateName;
    this.nextExpId = refresherExpId;

    this.statsReportingBackendApiService
      .recordLeaveForRefresherExpAsync(
        this.explorationVersion,
        refresherExpId,
        stateName,
        this.sessionId,
        this.stateStopwatch.getTimeInSecs(),
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });
  }

  // Note that this also resets the stateStopwatch.
  // The type of oldParams is declared as Object since it can vary depending
  // on the oldStateName.
  recordStateTransition(
    oldStateName: string,
    newStateName: string,
    answer: string,
    oldParams: Object,
    isFirstHit: boolean,
    chapterNumber: string,
    cardCount: string,
    language: string
  ): void {
    this.createDefaultStateStatsMappingIfMissing(newStateName);
    this.aggregatedStats.state_stats_mapping[newStateName].total_hit_count += 1;
    if (isFirstHit) {
      this.aggregatedStats.state_stats_mapping[newStateName].first_hit_count +=
        1;
    }

    this.previousStateName = oldStateName;
    this.nextStateName = newStateName;

    this.statsReportingBackendApiService
      .recordStateHitAsync(
        this.stateStopwatch.getTimeInSecs(),
        this.explorationVersion,
        newStateName,
        oldParams,
        this.sessionId,
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });

    // Broadcast information about the state transition to listeners.
    this.messengerService.sendMessage(
      ServicesConstants.MESSENGER_PAYLOAD.STATE_TRANSITION,
      {
        explorationVersion: this.explorationVersion,
        jsonAnswer: JSON.stringify(answer),
        newStateName: newStateName,
        oldStateName: oldStateName,
        paramValues: oldParams,
      }
    );

    if (!this.statesVisited.has(newStateName)) {
      this.statesVisited.add(newStateName);
      this.siteAnalyticsService.registerNewCard(
        this.statesVisited.size,
        this.explorationId
      );
    }
    let numberOfStatesVisited = this.statesVisited.size;
    if (numberOfStatesVisited === this.MINIMUM_NUMBER_OF_VISITED_STATES) {
      let urlParams = this.urlService.getUrlParams();
      this.siteAnalyticsService.registerLessonEngagedWithEvent(
        this.explorationId,
        language
      );
      if (urlParams.hasOwnProperty('classroom_url_fragment')) {
        this.siteAnalyticsService.registerClassroomLessonEngagedWithEvent(
          urlParams.classroom_url_fragment,
          this.topicName,
          this.explorationTitle,
          this.explorationId,
          chapterNumber,
          cardCount,
          language
        );
      } else {
        this.siteAnalyticsService.registerCommunityLessonEngagedWithEvent(
          this.explorationId,
          language
        );
      }
      this.siteAnalyticsService.registerLessonActiveUse();
    }

    this.stateStopwatch.reset();
  }

  recordStateCompleted(stateName: string): void {
    this.createDefaultStateStatsMappingIfMissing(stateName);
    this.aggregatedStats.state_stats_mapping[stateName].num_completions += 1;

    this.currentStateName = stateName;

    this.statsReportingBackendApiService
      .recordStateCompletedAsync(
        this.explorationVersion,
        this.sessionId,
        stateName,
        this.stateStopwatch.getTimeInSecs(),
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });
  }

  // The type of params is declared as Object since it can vary depending
  // on the stateName.
  recordExplorationCompleted(
    stateName: string,
    params: Object,
    chapterNumber: string,
    cardCount: string,
    language: string
  ): void {
    this.aggregatedStats.num_completions += 1;
    this.currentStateName = stateName;

    this.statsReportingBackendApiService
      .recordExplorationCompletedAsync(
        this.stateStopwatch.getTimeInSecs(),
        this.optionalCollectionId,
        params,
        this.sessionId,
        stateName,
        this.explorationVersion,
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });

    this.messengerService.sendMessage(
      ServicesConstants.MESSENGER_PAYLOAD.EXPLORATION_COMPLETED,
      {
        explorationVersion: this.explorationVersion,
        paramValues: params,
      }
    );

    this.siteAnalyticsService.registerFinishExploration(this.explorationId);
    let urlParams = this.urlService.getUrlParams();
    if (urlParams.hasOwnProperty('classroom_url_fragment')) {
      this.siteAnalyticsService.registerCuratedLessonCompleted(
        urlParams.classroom_url_fragment,
        this.topicName,
        this.explorationTitle,
        this.explorationId,
        chapterNumber,
        cardCount,
        language
      );
    } else {
      this.siteAnalyticsService.registerCommunityLessonCompleted(
        this.explorationId
      );
    }

    this.postStatsToBackend();
    this.playthroughService.recordExplorationQuitAction(
      stateName,
      this.stateStopwatch.getTimeInSecs()
    );

    this.explorationIsComplete = true;
  }

  // The type of params is declared as Object since it can vary depending
  // on the stateName.
  recordAnswerSubmitted(
    stateName: string,
    params: Object,
    answer: string,
    explorationId: string,
    answerIsCorrect: boolean,
    answerGroupIndex: number,
    ruleIndex: number,
    classificationCategorization: string,
    feedbackIsUseful: boolean
  ): void {
    this.createDefaultStateStatsMappingIfMissing(stateName);
    this.aggregatedStats.state_stats_mapping[stateName].total_answers_count +=
      1;
    if (feedbackIsUseful) {
      this.aggregatedStats.state_stats_mapping[
        stateName
      ].useful_feedback_count += 1;
    }
    this.currentStateName = stateName;

    this.statsReportingBackendApiService
      .recordAnswerSubmittedAsync(
        answer,
        params,
        this.explorationVersion,
        this.sessionId,
        this.stateStopwatch.getTimeInSecs(),
        stateName,
        answerGroupIndex,
        ruleIndex,
        classificationCategorization,
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });

    this.siteAnalyticsService.registerAnswerSubmitted(
      explorationId,
      answerIsCorrect
    );
  }

  // The type of params is declared as Object since it can vary depending
  // on the stateName.
  recordMaybeLeaveEvent(stateName: string, params: Object): void {
    this.currentStateName = stateName;

    this.statsReportingBackendApiService
      .recordMaybeLeaveEventAsync(
        this.stateStopwatch.getTimeInSecs(),
        this.optionalCollectionId,
        params,
        this.sessionId,
        stateName,
        this.explorationVersion,
        this.explorationId,
        this.currentStateName,
        this.nextExpId,
        this.previousStateName,
        this.nextStateName
      )
      .then(() => {
        // Required for the post operation to deliver data to backend.
      });

    this.postStatsToBackend();

    this.playthroughService.recordExplorationQuitAction(
      stateName,
      this.stateStopwatch.getTimeInSecs()
    );
    this.playthroughService.storePlaythrough();
  }

  recordAnswerSubmitAction(
    stateName: string,
    destStateName: string,
    interactionId: string,
    answer: string,
    feedback: string
  ): void {
    this.playthroughService.recordAnswerSubmitAction(
      stateName,
      destStateName,
      interactionId,
      answer,
      feedback,
      this.stateStopwatch.getTimeInSecs()
    );
  }
}
angular
  .module('oppia')
  .factory('StatsReportingService', downgradeInjectable(StatsReportingService));