Skip to content

Commit 5d59886

Browse files
committed
SF-3622 Definitively associate draft generation metrics
Records SF request id to help find related events. Drafting event metrics that are associated with the same NMT draft generation request have a 'tags.draftGenerationRequestId' written to them with an SF-specific id. This id is transferred around by Activity.Current.Tags, by method arguments, and looked up by finding a BuildProjectAsync event with a Serval build id. When SMT is used, `null` is passed for draftGenerationRequestId method arguments. The frontend draft-jobs component considers event metrics using both the older grouping logic as well as a new grouping system which uses the draftGenerationRequestId to group events. The new system also considers the BuildCompletedAsync event to be the finishing event for a job. A bug represented in the sample data was corrected to show the incorrect behaviour of recording sf project id in the "userId" field, rather than recording sf user id as a sf project id.
1 parent 570ab8a commit 5d59886

File tree

21 files changed

+1758
-166
lines changed

21 files changed

+1758
-166
lines changed

.github/copilot-instructions.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ This repository contains three interconnected applications:
4242

4343
- Write unit tests for new components and services
4444
- Follow existing patterns for mocking dependencies
45-
- Use TestEnvironment pattern from existing tests. Use the TestEnvironment class pattern rather than using a `beforeEach`.
45+
- Use TestEnvironment pattern from existing tests. Use the TestEnvironment class pattern rather than using a `beforeEach`. Do not put helper functions outside of TestEnvironment classes; helper functions or setup functions should be in the TestEnvironment class.
4646
- Test both online and offline scenarios
4747
- Test permission boundaries
4848

@@ -53,14 +53,20 @@ This repository contains three interconnected applications:
5353
- Do not add method comments unless the method would be unclear to an experienced developer.
5454
- Do put comments into the code to make it more clear what is going on if it would not be obvious to an experienced developer.
5555
- Do put comments into the code if the intent is not clear from the code.
56-
- All classes should have a comment to briefly explain why it is there and what its purpose is in the overall system, even if it seems obvious.
57-
- Please do not fail to add a comment to any classes that are created. All classes should have a comment.
56+
- All classes and interfaces should have a comment to briefly explain why it is there and what its purpose is in the overall system, even if it seems obvious.
57+
- Please do not fail to add a comment to any classes or interfaces that are created. All classes and interfaces should have a comment.
5858

5959
# TypeScript language
6060

6161
- Use explicit true/false/null/undefined rather than truthy/falsy
6262
- Never rely on JavaScript's truthy or falsy. Instead, work with actual true, false, null, and undefined values, rather than relying on their interpretation as truthy or falsy. For example, if `count` might be null, or undefined, or zero, don't write code like `if (count)` or `const foo:string = someVariable ? 'a' : 'b'`. Instead, inspect for the null, undefined, or zero rather than letting the value be interpreted as truthy for falsy. For example, use `if (count == null)` or `const foo:string = someVariable != null 'a' : 'b'` or `if (count > 0)`.
63-
- Specify types where not obvious, such as when declaring variables and arguments, and for function return types.
63+
- Specify types when declaring variables, arguments, and for function return types. For example, don't write
64+
`const projectId = this.determineProjectId();` or
65+
`const buildEvents = eventsSorted.filter(...);` or
66+
`const buildEvent = buildEvents[0];`. Instead, write
67+
`const projectId: string | undefined = this.determineProjectId();` and
68+
`const buildEvents: EventMetric[] = eventsSorted.filter(...);` and
69+
`const buildEvent: EventMetric | undefined = buildEvents[0];`.
6470
- Use `@if {}` syntax rather than `*ngIf` syntax.
6571
- Although interacting with existing code and APIs may necessitate the use of `null`, when writing new code, prefer using `undefined` rather than `null`.
6672
- Fields that are of type Subject or BehaviorSubject should have names that end with a `$`.
@@ -72,6 +78,7 @@ This repository contains three interconnected applications:
7278
- It is better to write code that is verbose and understandable than terse and concise.
7379
- It is better to explicitly check for and handle problems, or prevent problems from happening, than to assume problems will not happen.
7480
- Corner-cases happen. They should be handled in code.
81+
- Please don't change existing code without good justification. Existing code largely works and changing it will cause work for code review. Leave existing code as is when possible.
7582

7683
# Running commands
7784

src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metric.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface EventMetric {
99
timeStamp: string;
1010
userId?: string;
1111
executionTime?: string;
12+
tags?: { [key: string]: any | undefined };
1213
}
1314

1415
export enum EventScope {

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.spec.ts

Lines changed: 255 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { By } from '@angular/platform-browser';
77
import { provideNoopAnimations } from '@angular/platform-browser/animations';
88
import { ActivatedRoute, provideRouter } from '@angular/router';
99
import { BehaviorSubject } from 'rxjs';
10-
import { anything, mock, when } from 'ts-mockito';
10+
import { anything, capture, mock, resetCalls, verify, when } from 'ts-mockito';
1111
import { AuthService } from 'xforge-common/auth.service';
1212
import { DialogService } from 'xforge-common/dialog.service';
1313
import { I18nService } from 'xforge-common/i18n.service';
@@ -22,6 +22,7 @@ import { SF_TYPE_REGISTRY } from '../core/models/sf-type-registry';
2222
import { SFProjectService } from '../core/sf-project.service';
2323
import { EventMetric, EventScope } from '../event-metrics/event-metric';
2424
import { DraftJob, DraftJobsComponent } from './draft-jobs.component';
25+
import { JobDetailsDialogComponent } from './job-details-dialog.component';
2526
import sampleEvents from './sample-events.json';
2627
import { ServalAdministrationService } from './serval-administration.service';
2728

@@ -94,7 +95,7 @@ describe('DraftJobsComponent', () => {
9495
expect(draftJob!.startEvent!.id).toEqual('event-metric-05');
9596
expect(draftJob!.buildEvent!.id).toEqual('event-metric-04');
9697
expect(draftJob!.cancelEvent).toBeUndefined();
97-
expect(draftJob!.finishEvent!.id).toEqual('event-metric-03');
98+
expect(draftJob!.finishEvent!.id).toEqual('event-metric-01');
9899
}));
99100

100101
it('cancelled jobs', fakeAsync(() => {
@@ -109,7 +110,7 @@ describe('DraftJobsComponent', () => {
109110
expect(draftJob!.startEvent!.id).toEqual('event-metric-14');
110111
expect(draftJob!.buildEvent!.id).toEqual('event-metric-13');
111112
expect(draftJob!.cancelEvent!.id).toEqual('event-metric-12');
112-
expect(draftJob!.finishEvent).toBeUndefined();
113+
expect(draftJob!.finishEvent!.id).toEqual('event-metric-11');
113114
}));
114115

115116
it('faulted jobs', fakeAsync(() => {
@@ -144,6 +145,227 @@ describe('DraftJobsComponent', () => {
144145
}));
145146
});
146147

148+
describe('associates older events into jobs', () => {
149+
// Older events did not have a draftGenerationRequestId and used another association method. They are stored in the sample events file and have an `A` in the various Ids used.
150+
151+
it('successful jobs', fakeAsync(() => {
152+
const env = new TestEnvironment();
153+
env.wait();
154+
// The Serval build id and event metric ids are written here from looking at the sample data.
155+
const servalBuildId = 'serval-build-A1';
156+
157+
const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job;
158+
// Confirm test setup.
159+
expect(draftJob.status).toEqual('success');
160+
161+
expect(draftJob!.startEvent!.id).toEqual('event-metric-A05');
162+
expect(draftJob!.buildEvent!.id).toEqual('event-metric-A04');
163+
expect(draftJob!.cancelEvent).toBeUndefined();
164+
// Notice that the prior event grouping can use ExecuteWebhookAsync for a finish event rather than BuildCompletedAsync.
165+
expect(draftJob!.finishEvent!.id).toEqual('event-metric-A03');
166+
}));
167+
168+
it('cancelled jobs', fakeAsync(() => {
169+
const env = new TestEnvironment();
170+
env.wait();
171+
const servalBuildId = 'serval-build-A2';
172+
173+
const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job;
174+
// Confirm test setup.
175+
expect(draftJob.status).toEqual('cancelled');
176+
177+
expect(draftJob!.startEvent!.id).toEqual('event-metric-A14');
178+
expect(draftJob!.buildEvent!.id).toEqual('event-metric-A13');
179+
expect(draftJob!.cancelEvent!.id).toEqual('event-metric-A12');
180+
// Notice that the prior event grouping did not define a 'finishEvent' for cancelled jobs.
181+
expect(draftJob!.finishEvent).toBeUndefined();
182+
}));
183+
184+
it('faulted jobs', fakeAsync(() => {
185+
const env = new TestEnvironment();
186+
env.wait();
187+
const servalBuildId = 'serval-build-A4';
188+
189+
const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job;
190+
// Confirm test setup.
191+
expect(draftJob.status).toEqual('failed');
192+
193+
expect(draftJob!.startEvent!.id).toEqual('event-metric-A17');
194+
expect(draftJob!.buildEvent!.id).toEqual('event-metric-A16');
195+
expect(draftJob!.cancelEvent).toBeUndefined();
196+
expect(draftJob!.finishEvent!.id).toEqual('event-metric-A15');
197+
}));
198+
199+
it('in-progress jobs', fakeAsync(() => {
200+
// This Serval job had StartPreTranslationBuildAsync and BuildProjectAsync but nothing more yet.
201+
const env = new TestEnvironment();
202+
env.wait();
203+
const servalBuildId = 'serval-build-A5';
204+
205+
const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job;
206+
// Confirm test setup.
207+
expect(draftJob.status).toEqual('running');
208+
209+
expect(draftJob!.startEvent!.id).toEqual('event-metric-A07');
210+
expect(draftJob!.buildEvent!.id).toEqual('event-metric-A06');
211+
expect(draftJob!.cancelEvent).toBeUndefined();
212+
expect(draftJob!.finishEvent).toBeUndefined();
213+
}));
214+
});
215+
216+
describe('job details dialog data', () => {
217+
it('includes draft generation request id in dialog data', fakeAsync(() => {
218+
const env = new TestEnvironment();
219+
env.wait();
220+
const jobRow = env.component.rows.find(
221+
row => row.job.draftGenerationRequestId != null && row.job.buildId != null
222+
);
223+
224+
expect(jobRow).toBeDefined();
225+
if (jobRow == null) {
226+
return;
227+
}
228+
229+
resetCalls(mockedDialogService);
230+
when(mockedDialogService.openMatDialog(anything(), anything())).thenReturn({} as any);
231+
232+
env.component.openJobDetailsDialog(jobRow.job);
233+
234+
verify(mockedDialogService.openMatDialog(anything(), anything())).once();
235+
const [component, config] = capture(mockedDialogService.openMatDialog as any).last();
236+
237+
expect(component).toBe(JobDetailsDialogComponent);
238+
const dialogData: any = (config as any)?.data;
239+
expect(dialogData.draftGenerationRequestId).toEqual(jobRow.job.draftGenerationRequestId);
240+
}));
241+
});
242+
243+
describe('event group validation', () => {
244+
it('throws when grouped events contain mismatched request ids', fakeAsync(() => {
245+
// Suppose the createJobFromRequestGroup method is called with a set of event metrics that do not have the same draftGenerationRequestId. Throw.
246+
247+
const env = new TestEnvironment({ hasEvents: false });
248+
env.wait();
249+
const componentAny: any = env.component;
250+
const start = env.createEvent('start-1', 'StartPreTranslationBuildAsync');
251+
const build = env.createEvent('build-1', 'BuildProjectAsync', { timeStamp: '2025-01-01T00:01:00.000Z' });
252+
const finish = env.createEvent('finish-1', 'BuildCompletedAsync', {
253+
timeStamp: '2025-01-01T00:02:00.000Z',
254+
payload: { buildState: 'Completed', sfProjectId: 'sf-test-project', buildId: 'build-1' }
255+
});
256+
const mismatched = env.createEvent('mismatch-1', 'ExecuteWebhookAsync', {
257+
tags: { draftGenerationRequestId: 'req-2' },
258+
timeStamp: '2025-01-01T00:03:00.000Z'
259+
});
260+
261+
expect(() => componentAny['createJobFromRequestGroup']('req-1', [start, build, finish, mismatched])).toThrowError(
262+
/share/i
263+
);
264+
}));
265+
266+
it('throws when grouped events contain multiple start events', fakeAsync(() => {
267+
const env = new TestEnvironment({ hasEvents: false });
268+
env.wait();
269+
const componentAny: any = env.component;
270+
const startOne = env.createEvent('start-1', 'StartPreTranslationBuildAsync');
271+
const startTwo = env.createEvent('start-2', 'StartPreTranslationBuildAsync', {
272+
timeStamp: '2025-01-01T00:00:30.000Z'
273+
});
274+
const build = env.createEvent('build-1', 'BuildProjectAsync', { timeStamp: '2025-01-01T00:01:00.000Z' });
275+
276+
expect(() => componentAny['createJobFromRequestGroup']('req-1', [startOne, startTwo, build])).toThrowError(
277+
/exactly one startpretranslationbuildasync/i
278+
);
279+
}));
280+
281+
it('throws when grouped events are missing a start event', fakeAsync(() => {
282+
const env = new TestEnvironment({ hasEvents: false });
283+
env.wait();
284+
const componentAny: any = env.component;
285+
const build = env.createEvent('build-1', 'BuildProjectAsync', { timeStamp: '2025-01-01T00:01:00.000Z' });
286+
287+
expect(() => componentAny['createJobFromRequestGroup']('req-1', [build])).toThrowError(
288+
/exactly one startpretranslationbuildasync/i
289+
);
290+
}));
291+
292+
it('throws when grouped events contain multiple build events', fakeAsync(() => {
293+
const env = new TestEnvironment({ hasEvents: false });
294+
env.wait();
295+
const componentAny: any = env.component;
296+
const start = env.createEvent('start-1', 'StartPreTranslationBuildAsync');
297+
const buildOne = env.createEvent('build-1', 'BuildProjectAsync', { timeStamp: '2025-01-01T00:01:00.000Z' });
298+
const buildTwo = env.createEvent('build-2', 'BuildProjectAsync', { timeStamp: '2025-01-01T00:01:30.000Z' });
299+
300+
expect(() => componentAny['createJobFromRequestGroup']('req-1', [start, buildOne, buildTwo])).toThrowError(
301+
/more than one buildprojectasync/i
302+
);
303+
}));
304+
305+
it('throws when grouped events contain multiple build completed events', fakeAsync(() => {
306+
const env = new TestEnvironment({ hasEvents: false });
307+
env.wait();
308+
const componentAny: any = env.component;
309+
const start = env.createEvent('start-1', 'StartPreTranslationBuildAsync');
310+
const build = env.createEvent('build-1', 'BuildProjectAsync', { timeStamp: '2025-01-01T00:01:00.000Z' });
311+
const finishOne = env.createEvent('finish-1', 'BuildCompletedAsync', {
312+
timeStamp: '2025-01-01T00:02:00.000Z',
313+
payload: { buildState: 'Completed', sfProjectId: 'sf-test-project', buildId: 'build-1' }
314+
});
315+
const finishTwo = env.createEvent('finish-2', 'BuildCompletedAsync', {
316+
timeStamp: '2025-01-01T00:03:00.000Z',
317+
payload: { buildState: 'Completed', sfProjectId: 'sf-test-project', buildId: 'build-1' }
318+
});
319+
320+
expect(() =>
321+
componentAny['createJobFromRequestGroup']('req-1', [start, build, finishOne, finishTwo])
322+
).toThrowError(/more than one buildcompletedasync/i);
323+
}));
324+
325+
it('throws when there is a cancel event without a BuildCompleted cancel state', fakeAsync(() => {
326+
// When a cancel event happens, the current behavior is that the BuildCompletedAsync event also has a buildState of 'Canceled'. The createJobFromRequestGroup method will expect that.
327+
328+
const env = new TestEnvironment({ hasEvents: false });
329+
env.wait();
330+
const componentAny: any = env.component;
331+
const start = env.createEvent('start-1', 'StartPreTranslationBuildAsync');
332+
const build = env.createEvent('build-1', 'BuildProjectAsync', { timeStamp: '2025-01-01T00:01:00.000Z' });
333+
const finish = env.createEvent('finish-1', 'BuildCompletedAsync', {
334+
timeStamp: '2025-01-01T00:02:00.000Z',
335+
payload: { buildState: 'Completed', sfProjectId: 'sf-test-project', buildId: 'build-1' }
336+
});
337+
const cancel = env.createEvent('cancel-1', 'CancelPreTranslationBuildAsync', {
338+
timeStamp: '2025-01-01T00:02:30.000Z'
339+
});
340+
341+
expect(() => componentAny['createJobFromRequestGroup']('req-1', [start, build, finish, cancel])).toThrowError(
342+
/Cancel/i
343+
);
344+
}));
345+
});
346+
347+
describe('processDraftJobs', () => {
348+
it('skips request groups missing a start event', fakeAsync(() => {
349+
// Suppose the user selects a dante range such that the start date+time is after a draft generation starts and before it finishes. The list of event metrics will have events about the job being processed, but there will not be a start event. Omit those jobs from the data we present.
350+
//
351+
// If the end date falls after a start event and before a job's finish event, we'll just show that as in progress.
352+
353+
const env = new TestEnvironment({ hasEvents: false });
354+
env.wait();
355+
356+
const buildEvent = env.createEvent('build-1', 'BuildProjectAsync', {
357+
timeStamp: '2025-01-01T00:01:00.000Z',
358+
tags: { draftGenerationRequestId: 'req-1' },
359+
result: 'build-1'
360+
});
361+
362+
env.component['draftEvents'] = [buildEvent];
363+
env.component['processDraftJobs']();
364+
// The job was not included in the data.
365+
expect(env.component.rows.length).toBe(0);
366+
}));
367+
});
368+
147369
describe('duration statistics', () => {
148370
describe('meanDuration', () => {
149371
it('should return undefined when rows is empty', fakeAsync(() => {
@@ -435,6 +657,34 @@ class TestEnvironment {
435657
});
436658
}
437659

660+
createEvent(id: string, eventType: string, overrides: Partial<EventMetric> = {}): EventMetric {
661+
const event: EventMetric = {
662+
id,
663+
eventType,
664+
timeStamp: '2025-01-01T00:00:00.000Z',
665+
scope: EventScope.Drafting,
666+
payload: {},
667+
userId: 'user-1',
668+
projectId: 'sf-test-project',
669+
result: undefined,
670+
executionTime: undefined,
671+
exception: undefined,
672+
tags: { draftGenerationRequestId: 'req-1' }
673+
};
674+
675+
if (overrides.timeStamp != null) event.timeStamp = overrides.timeStamp;
676+
if (overrides.scope != null) event.scope = overrides.scope;
677+
if (overrides.payload != null) event.payload = overrides.payload;
678+
if (overrides.userId !== undefined) event.userId = overrides.userId;
679+
if (overrides.projectId !== undefined) event.projectId = overrides.projectId;
680+
if (overrides.result !== undefined) event.result = overrides.result;
681+
if (overrides.executionTime !== undefined) event.executionTime = overrides.executionTime;
682+
if (overrides.exception !== undefined) event.exception = overrides.exception;
683+
if (overrides.tags != null) event.tags = overrides.tags;
684+
685+
return event;
686+
}
687+
438688
/**
439689
* Transforms JSON event data to EventMetric objects.
440690
* Transforms "timeStamp":{"$date":"foo"} to just "timeStamp".
@@ -450,7 +700,8 @@ class TestEnvironment {
450700
projectId: event.projectId,
451701
result: event.result,
452702
executionTime: event.executionTime,
453-
exception: event.exception
703+
exception: event.exception,
704+
tags: event.tags
454705
}));
455706
}
456707
}

0 commit comments

Comments
 (0)