Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SF-2392 Add event metric logging #2894

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { AuthGuard } from 'xforge-common/auth.guard';
import { SystemAdminAuthGuard } from 'xforge-common/system-admin-auth.guard';
import { SystemAdministrationComponent } from 'xforge-common/system-administration/system-administration.component';
import { ConnectProjectComponent } from './connect-project/connect-project.component';
import { EventMetricsAuthGuard } from './event-metrics/event-metrics-auth.guard';
import { EventMetricsComponent } from './event-metrics/event-metrics.component';
import { JoinComponent } from './join/join.component';
import { MyProjectsComponent } from './my-projects/my-projects.component';
import { ProjectComponent } from './project/project.component';
Expand All @@ -21,6 +23,7 @@ const routes: Routes = [
{ path: 'login', redirectTo: 'projects', pathMatch: 'full' },
{ path: 'join/:shareKey', component: JoinComponent },
{ path: 'join/:shareKey/:locale', component: JoinComponent },
{ path: 'projects/:projectId/event-log', component: EventMetricsComponent, canActivate: [EventMetricsAuthGuard] },
{ path: 'projects/:projectId/settings', component: SettingsComponent, canActivate: [SettingsAuthGuard] },
{ path: 'projects/:projectId/sync', component: SyncComponent, canActivate: [SyncAuthGuard] },
{ path: 'projects/:projectId', component: ProjectComponent, canActivate: [AuthGuard] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NgModule } from '@angular/core';
import { TranslocoModule } from '@ngneat/transloco';
import { ngfModule } from 'angular-file';
import { AngularSplitModule } from 'angular-split';
import { OwnerComponent } from 'xforge-common/owner/owner.component';
import { UICommonModule } from 'xforge-common/ui-common.module';
import { XForgeCommonModule } from 'xforge-common/xforge-common.module';
import { AudioPlayerComponent } from '../shared/audio/audio-player/audio-player.component';
Expand Down Expand Up @@ -55,6 +56,7 @@ import { TextAndAudioComponent } from './text-and-audio/text-and-audio.component
SharedModule,
UICommonModule,
XForgeCommonModule,
OwnerComponent,
AngularSplitModule,
ngfModule,
TranslocoModule
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,12 @@ const meta: Meta<CheckingCommentsComponent> = {
component: CheckingCommentsComponent,
decorators: [
moduleMetadata({
imports: [CommonModule, UICommonModule, I18nStoryModule],
imports: [CommonModule, UICommonModule, I18nStoryModule, OwnerComponent],
providers: [
{ provide: DialogService, useValue: instance(mockedDialogService) },
{ provide: UserService, useValue: instance(mockedUserService) }
],
declarations: [OwnerComponent, CheckingInputFormComponent]
declarations: [CheckingInputFormComponent]
})
],
args: defaultArgs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ describe('CheckingComponent', () => {
CheckingCommentsComponent,
CheckingComponent,
CheckingScriptureAudioPlayerComponent,
OwnerComponent,
CheckingQuestionsComponent,
CheckingTextComponent,
TextAndAudioComponent,
Expand All @@ -166,6 +165,7 @@ describe('CheckingComponent', () => {
SharedModule,
UICommonModule,
AvatarComponent,
OwnerComponent,
TestTranslocoModule,
TestOnlineStatusModule.forRoot(),
TestRealtimeModule.forRoot(SF_TYPE_REGISTRY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ describe('SFProjectService', () => {
}));
});

describe('onlineEventMetrics', () => {
it('should invoke the command service', fakeAsync(async () => {
const env = new TestEnvironment();
const projectId = 'project01';
const pageIndex = 0;
const pageSize = 20;
await env.service.onlineEventMetrics(projectId, pageIndex, pageSize);
verify(mockedCommandService.onlineInvoke(anything(), 'eventMetrics', anything())).once();
expect().nothing();
}));
});

class TestEnvironment {
readonly httpTestingController: HttpTestingController;
readonly service: SFProjectService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import { CommandService } from 'xforge-common/command.service';
import { LocationService } from 'xforge-common/location.service';
import { RealtimeQuery } from 'xforge-common/models/realtime-query';
import { ProjectService } from 'xforge-common/project.service';
import { QueryParameters } from 'xforge-common/query-parameters';
import { QueryParameters, QueryResults } from 'xforge-common/query-parameters';
import { RealtimeService } from 'xforge-common/realtime.service';
import { RetryingRequest, RetryingRequestService } from 'xforge-common/retrying-request.service';
import { TransceleratorQuestion } from '../checking/import-questions-dialog/import-questions-dialog.component';
import { EventMetric } from '../event-metrics/event-metric';
import { ShareLinkType } from '../shared/share/share-dialog.component';
import { InviteeStatus } from '../users/collaborators/collaborators.component';
import { BiblicalTermDoc } from './models/biblical-term-doc';
Expand Down Expand Up @@ -309,4 +310,8 @@ export class SFProjectService extends ProjectService<SFProject, SFProjectDoc> {
isValid
});
}

async onlineEventMetrics(projectId: string, pageIndex: number, pageSize: number): Promise<QueryResults<EventMetric>> {
return await this.onlineInvoke<QueryResults<EventMetric>>('eventMetrics', { projectId, pageIndex, pageSize });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<ng-container *transloco="let t; read: 'event_metric_dialog'">
<mat-dialog [dir]="i18n.direction">
<h2 mat-dialog-title>
{{ t("technical_details") }}
</h2>
<mat-dialog-content>
<h3>{{ t("event_type") }}</h3>
<div>
<pre>{{ data.eventType }}</pre>
</div>
<h3>{{ t("parameters") }}</h3>
<div>
<pre>{{ data.payload | json }}</pre>
</div>
@if (data.result != null) {
<h3>{{ t("result") }}</h3>
<div>
<pre>{{ data.result }}</pre>
</div>
}
@if (data.exception != null) {
<h3>{{ t("exception") }}</h3>
<div>
<pre>{{ data.exception }}</pre>
</div>
}
</mat-dialog-content>
<mat-dialog-actions [align]="'end'">
<button mat-button id="close-btn" [mat-dialog-close]="'cancel'">{{ t("close") }}</button>
</mat-dialog-actions>
</mat-dialog>
</ng-container>
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { NgModule } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick } from '@angular/core/testing';
import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ChildViewContainerComponent, configureTestingModule, TestTranslocoModule } from 'xforge-common/test-utils';
import { UICommonModule } from 'xforge-common/ui-common.module';
import { EventMetricDialogComponent } from './event-metric-dialog.component';

describe('EventMetricDialogComponent', () => {
configureTestingModule(() => ({
imports: [DialogTestModule, NoopAnimationsModule, UICommonModule]
}));

let dialog: MatDialog;
let viewContainerFixture: ComponentFixture<ChildViewContainerComponent>;

it('should allow user to close', fakeAsync(() => {
const env = new TestEnvironment();
env.clickElement(env.closeButton);
flush();
expect(env.afterCloseCallback).toHaveBeenCalledWith('cancel');
}));

class TestEnvironment {
fixture: ComponentFixture<ChildViewContainerComponent>;
component: EventMetricDialogComponent;
dialogRef: MatDialogRef<EventMetricDialogComponent>;

afterCloseCallback: jasmine.Spy;

constructor() {
this.afterCloseCallback = jasmine.createSpy('afterClose callback');
const config: MatDialogConfig = { data: { name: 'project01' } };
this.dialogRef = dialog.open(EventMetricDialogComponent, config);
this.dialogRef.afterClosed().subscribe(this.afterCloseCallback);
this.component = this.dialogRef.componentInstance;
this.fixture = viewContainerFixture;
this.fixture.detectChanges();
}

get overlayContainerElement(): HTMLElement {
return this.fixture.nativeElement.parentElement.querySelector('.cdk-overlay-container');
}

get closeButton(): HTMLElement {
return this.overlayContainerElement.querySelector('#close-btn') as HTMLElement;
}

clickElement(element: HTMLElement): void {
element.click();
this.fixture.detectChanges();
tick();
}
}

beforeEach(inject([MatDialog], (d: MatDialog) => {
dialog = d;
}));

beforeEach(() => {
viewContainerFixture = TestBed.createComponent(ChildViewContainerComponent);
});
});

@NgModule({
exports: [EventMetricDialogComponent],
imports: [EventMetricDialogComponent, TestTranslocoModule, UICommonModule],
providers: [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()]
})
class DialogTestModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { TranslocoModule } from '@ngneat/transloco';
import { I18nService } from 'xforge-common/i18n.service';
import { UICommonModule } from 'xforge-common/ui-common.module';
import { EventMetric } from './event-metric';

@Component({
selector: 'app-event-metric-dialog',
templateUrl: './event-metric-dialog.component.html',
styleUrls: ['./event-metric-dialog.component.scss'],
standalone: true,
imports: [CommonModule, MatDialogModule, TranslocoModule, UICommonModule]
})
export class EventMetricDialogComponent {
constructor(
readonly i18n: I18nService,
@Inject(MAT_DIALOG_DATA) public data: EventMetric
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface EventMetric {
eventType: string;
exception?: string;
id: string;
payload: { [key: string]: any };
projectId?: string;
result?: any;
scope: EventScope;
timeStamp: string;
userId?: string;
}

export enum EventScope {
None = 'None',
Settings = 'Settings',
Sync = 'Sync',
Drafting = 'Drafting',
Checking = 'Checking'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role';
import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role';
import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data';
import { of } from 'rxjs';
import { anything, mock, when } from 'ts-mockito';
import { AuthGuard } from 'xforge-common/auth.guard';
import { AuthService } from 'xforge-common/auth.service';
import { configureTestingModule } from 'xforge-common/test-utils';
import { UserService } from 'xforge-common/user.service';
import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc';
import { SFProjectService } from '../core/sf-project.service';
import { EventMetricsAuthGuard } from './event-metrics-auth.guard';

const mockedAuthGuard = mock(AuthGuard);
const mockedAuthService = mock(AuthService);
const mockedProjectService = mock(SFProjectService);
const mockedUserService = mock(UserService);

describe('EventMetricsAuthGuard', () => {
const project01 = 'project01';
const project02 = 'project02';
const user01 = 'user01';
configureTestingModule(() => ({
providers: [
{ provide: AuthGuard, useMock: mockedAuthGuard },
{ provide: AuthService, useMock: mockedAuthService },
{ provide: SFProjectService, useMock: mockedProjectService },
{ provide: UserService, useMock: mockedUserService }
]
}));

it('can activate if user is logged in and has ServalAdmin role', fakeAsync(() => {
const env = new TestEnvironment(true, SystemRole.ServalAdmin);

env.service.canActivate(env.getActivatedRouteSnapshot(project02), {} as RouterStateSnapshot).subscribe(result => {
expect(result).toBe(true);
});

env.wait();
}));

it('can activate if user is logged in and has SystemAdmin role', fakeAsync(() => {
const env = new TestEnvironment(true, SystemRole.SystemAdmin);

env.service.canActivate(env.getActivatedRouteSnapshot(project02), {} as RouterStateSnapshot).subscribe(result => {
expect(result).toBe(true);
});

env.wait();
}));

it('can activate if user is logged in and has pt_administrator role', fakeAsync(() => {
const env = new TestEnvironment(true, SystemRole.User);

env.service.canActivate(env.getActivatedRouteSnapshot(project01), {} as RouterStateSnapshot).subscribe(result => {
expect(result).toBe(true);
});

env.wait();
}));

it('cannot activate if user is not logged in', fakeAsync(() => {
const env = new TestEnvironment(false, SystemRole.None);

env.service.canActivate(env.getActivatedRouteSnapshot(project02), {} as RouterStateSnapshot).subscribe(result => {
expect(result).toBe(false);
});

env.wait();
}));

it('cannot activate if user is logged in but does not have any administrator role', fakeAsync(() => {
const env = new TestEnvironment(true, SystemRole.User);

env.service.canActivate(env.getActivatedRouteSnapshot(project02), {} as RouterStateSnapshot).subscribe(result => {
expect(result).toBe(false);
});

env.wait();
}));

class TestEnvironment {
readonly service: EventMetricsAuthGuard;

constructor(isLoggedIn: boolean, role: SystemRole) {
this.service = TestBed.inject(EventMetricsAuthGuard);
when(mockedAuthGuard.canActivate(anything(), anything())).thenReturn(of(isLoggedIn));
when(mockedAuthGuard.allowTransition()).thenReturn(of(isLoggedIn));
when(mockedAuthService.currentUserRoles).thenReturn([role]);
when(mockedUserService.currentUserId).thenReturn(user01);

when(mockedProjectService.getProfile(project01)).thenReturn(
Promise.resolve({
data: createTestProjectProfile({ userRoles: { user01: SFProjectRole.ParatextAdministrator } })
} as SFProjectProfileDoc)
);
when(mockedProjectService.getProfile(project02)).thenReturn(
Promise.resolve({
data: createTestProjectProfile()
} as SFProjectProfileDoc)
);
}

getActivatedRouteSnapshot(projectId: string): ActivatedRouteSnapshot {
const snapshot = new ActivatedRouteSnapshot();
snapshot.params = { projectId: projectId };
return snapshot;
}

wait(): void {
tick();
}
}
});
Loading
Loading