diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metric.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metric.ts index 72f2cb4b3e..17a473b9a0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metric.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metric.ts @@ -9,6 +9,7 @@ export interface EventMetric { timeStamp: string; userId?: string; executionTime?: string; + tags?: { [key: string]: any | undefined }; } export enum EventScope { diff --git a/src/SIL.XForge.Scripture/Services/IMachineProjectService.cs b/src/SIL.XForge.Scripture/Services/IMachineProjectService.cs index 67a740207a..a726163d00 100644 --- a/src/SIL.XForge.Scripture/Services/IMachineProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/IMachineProjectService.cs @@ -15,7 +15,8 @@ Task BuildProjectForBackgroundJobAsync( string curUserId, BuildConfig buildConfig, bool preTranslate, - CancellationToken cancellationToken + string? draftGenerationRequestId = null, + CancellationToken cancellationToken = default ); Task GetProjectZipAsync(string sfProjectId, Stream outputStream, CancellationToken cancellationToken); Task RemoveProjectAsync(string sfProjectId, bool preTranslate, CancellationToken cancellationToken); diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index 1a0d5fe97b..2b80903d11 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Security.Cryptography; using System.Text; @@ -584,6 +585,12 @@ public async Task BuildCompletedAsync(string sfProjectId, string buildId, string { try { + string? draftGenerationRequestId = await GetDraftGenerationRequestIdForBuildAsync(buildId); + if (!string.IsNullOrEmpty(draftGenerationRequestId)) + { + Activity.Current?.AddTag("draftGenerationRequestId", draftGenerationRequestId); + } + // Retrieve the build started from the event metric. We do this as there may be multiple builds started, // and this ensures that only builds that want to send an email will have one sent. var eventMetrics = await eventMetricService.GetEventMetricsAsync( @@ -680,8 +687,15 @@ await projectSecrets.UpdateAsync( cancellationToken ); + string buildId = translationBuild.Id; + string? draftGenerationRequestId = await GetDraftGenerationRequestIdForBuildAsync(buildId); + if (!string.IsNullOrEmpty(draftGenerationRequestId)) + { + Activity.Current?.AddTag("draftGenerationRequestId", draftGenerationRequestId); + } + // Return the build id so it can be logged - return translationBuild.Id; + return buildId; } catch (ServalApiException e) when (e.StatusCode == StatusCodes.Status404NotFound) { @@ -765,6 +779,13 @@ public async Task ExecuteWebhookAsync(string json, string signature) return; } + // Add the draftGenerationRequestId to associate with other events. + string? draftGenerationRequestId = await GetDraftGenerationRequestIdForBuildAsync(buildId); + if (!string.IsNullOrEmpty(draftGenerationRequestId)) + { + Activity.Current?.AddTag("draftGenerationRequestId", draftGenerationRequestId); + } + // Record that the webhook was run successfully var arguments = new Dictionary { @@ -1800,17 +1821,23 @@ await projectSecrets.UpdateAsync( ); // Notify any SignalR clients subscribed to the project + string? buildId = translationBuild?.Id; await hubContext.NotifyBuildProgress( sfProjectId, - new ServalBuildState + new ServalBuildState { BuildId = buildId, State = nameof(ServalData.PreTranslationsRetrieved) } + ); + + if (!string.IsNullOrEmpty(buildId)) + { + string? draftGenerationRequestId = await GetDraftGenerationRequestIdForBuildAsync(buildId); + if (!string.IsNullOrEmpty(draftGenerationRequestId)) { - BuildId = translationBuild?.Id, - State = nameof(ServalData.PreTranslationsRetrieved), + Activity.Current?.AddTag("draftGenerationRequestId", draftGenerationRequestId); } - ); + } // Return the build id - return translationBuild?.Id; + return buildId; } } catch (TaskCanceledException e) when (e.InnerException is not TimeoutException) @@ -1879,6 +1906,7 @@ public async Task StartBuildAsync(string curUserId, string sfProjectId, Cancella curUserId, new BuildConfig { ProjectId = sfProjectId }, false, + null, CancellationToken.None ), null, @@ -1904,6 +1932,8 @@ public async Task StartPreTranslationBuildAsync( CancellationToken cancellationToken ) { + string draftGenerationRequestId = ObjectId.GenerateNewId().ToString(); + Activity.Current?.AddTag("draftGenerationRequestId", draftGenerationRequestId); // Load the project from the realtime service await using IConnection conn = await realtimeService.ConnectAsync(curUserId); IDocument projectDoc = await conn.FetchAsync(buildConfig.ProjectId); @@ -2018,7 +2048,14 @@ await projectDoc.SubmitJson0OpAsync(op => // so that the interceptor functions for BuildProjectAsync(). jobId = backgroundJobClient.ContinueJobWith( jobId, - r => r.BuildProjectForBackgroundJobAsync(curUserId, buildConfig, true, CancellationToken.None) + r => + r.BuildProjectForBackgroundJobAsync( + curUserId, + buildConfig, + true, + draftGenerationRequestId, + CancellationToken.None + ) ); // Set the pre-translation queued date and time, and hang fire job id @@ -2586,6 +2623,29 @@ CancellationToken cancellationToken return project; } + /// + /// Gets the SF-specific draft generation request identifier for a build by looking up the BuildProjectAsync event. + /// + /// The Serval build identifier. + /// The draft generation request identifier, or null if not found. + private async Task GetDraftGenerationRequestIdForBuildAsync(string buildId) + { + // BuildProjectAsync events serve as a record of what Serval build id corresponds to what draft generation + // request id. + const int lookupTimeframeDays = 60; + DateTime startDate = DateTime.UtcNow.AddDays(-lookupTimeframeDays); + QueryResults buildProjectEvents = await eventMetricService.GetEventMetricsAsync( + projectId: null, + scopes: [EventScope.Drafting], + eventTypes: [nameof(MachineProjectService.BuildProjectAsync)], + fromDate: startDate + ); + EventMetric? buildEvent = buildProjectEvents.Results.FirstOrDefault(e => e.Result?.ToString() == buildId); + return (buildEvent?.Tags?.TryGetValue("draftGenerationRequestId", out BsonValue? requestId) == true) + ? requestId?.AsString + : null; + } + private async Task GetTranslationIdAsync( string sfProjectId, bool preTranslate, diff --git a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs index 27a6126132..bea12c3132 100644 --- a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs @@ -106,6 +106,9 @@ public async Task AddSmtProjectAsync(string sfProjectId, CancellationTok /// The current user identifier. /// The build configuration. /// If true use NMT; otherwise if false use SMT. + /// + /// The draft generation request identifier (NMT only). Pass null for SMT builds. + /// /// The cancellation token. /// An asynchronous task. /// @@ -115,12 +118,18 @@ public async Task BuildProjectForBackgroundJobAsync( string curUserId, BuildConfig buildConfig, bool preTranslate, - CancellationToken cancellationToken + string? draftGenerationRequestId = null, + CancellationToken cancellationToken = default ) { + if (!string.IsNullOrEmpty(draftGenerationRequestId)) + { + System.Diagnostics.Activity.Current?.AddTag("draftGenerationRequestId", draftGenerationRequestId); + } + try { - await BuildProjectAsync(curUserId, buildConfig, preTranslate, cancellationToken); + await BuildProjectAsync(curUserId, buildConfig, preTranslate, draftGenerationRequestId, cancellationToken); } catch (TaskCanceledException e) when (e.InnerException is not TimeoutException) { @@ -598,8 +607,11 @@ await projectDoc.SubmitJson0OpAsync(op => /// The current user identifier. /// The build configuration. /// If true use NMT; otherwise if false use SMT. + /// + /// The draft generation request identifier (NMT only). Pass null for SMT builds. + /// /// The cancellation token. - /// An asynchronous task. + /// Serval build ID /// The project or project secret could not be found. /// The language of the source project was not specified. /// @@ -616,9 +628,15 @@ public virtual async Task BuildProjectAsync( string curUserId, BuildConfig buildConfig, bool preTranslate, - CancellationToken cancellationToken + string? draftGenerationRequestId = null, + CancellationToken cancellationToken = default ) { + if (!string.IsNullOrEmpty(draftGenerationRequestId)) + { + System.Diagnostics.Activity.Current?.AddTag("draftGenerationRequestId", draftGenerationRequestId); + } + // Load the target project secrets, so we can get the translation engine ID if ( !(await projectSecrets.TryGetAsync(buildConfig.ProjectId, cancellationToken)).TryResult( diff --git a/src/SIL.XForge.Scripture/Services/SyncService.cs b/src/SIL.XForge.Scripture/Services/SyncService.cs index c78f0ecf7b..976526dc9f 100644 --- a/src/SIL.XForge.Scripture/Services/SyncService.cs +++ b/src/SIL.XForge.Scripture/Services/SyncService.cs @@ -198,6 +198,7 @@ await projectSecrets.UpdateAsync( syncConfig.UserId, new BuildConfig { ProjectId = syncConfig.ProjectId }, false, + null, CancellationToken.None ), null, diff --git a/src/SIL.XForge/EventMetrics/EventMetric.cs b/src/SIL.XForge/EventMetrics/EventMetric.cs index 2b2968fe84..5b2e2ceb66 100644 --- a/src/SIL.XForge/EventMetrics/EventMetric.cs +++ b/src/SIL.XForge/EventMetrics/EventMetric.cs @@ -36,7 +36,7 @@ public class EventMetric : IIdentifiable public string Id { get; set; } = string.Empty; /// - /// Gets or sets the event payload. + /// Gets or sets the event payload, which contains the arguments given to the RPC or method call being recorded. /// /// /// If you are querying by projectId or userId, that will be done here. @@ -73,4 +73,12 @@ public class EventMetric : IIdentifiable /// Gets or sets the event user identifier. /// public string? UserId { get; set; } + + /// + /// Additional event metadata. For example, from items in an Activity Tags. + /// + /// + /// Keys should be normalized to lowerCamelCase. + /// + public Dictionary? Tags { get; set; } } diff --git a/src/SIL.XForge/EventMetrics/EventMetricLogger.cs b/src/SIL.XForge/EventMetrics/EventMetricLogger.cs index f5cbff354d..735d6fd47b 100644 --- a/src/SIL.XForge/EventMetrics/EventMetricLogger.cs +++ b/src/SIL.XForge/EventMetrics/EventMetricLogger.cs @@ -56,6 +56,9 @@ public void Intercept(IInvocation invocation) is LogEventMetricAttribute logEventMetricAttribute ) { + // Start an activity so additional information can be logged via tags + Activity activity = new Activity("log_event_metric").Start(); + // Invoke the method, then record its event metrics Task task; Stopwatch stopwatch = Stopwatch.StartNew(); @@ -68,21 +71,21 @@ is LogEventMetricAttribute logEventMetricAttribute task = methodTask.ContinueWith(t => { stopwatch.Stop(); - return SaveEventMetricAsync(stopwatch.Elapsed, t.Exception); + return SaveEventMetricAsync(stopwatch.Elapsed, activity, t.Exception); }); } else { // Save the event metric in another thread after the method has executed stopwatch.Stop(); - task = Task.Run(() => SaveEventMetricAsync(stopwatch.Elapsed)); + task = Task.Run(() => SaveEventMetricAsync(stopwatch.Elapsed, activity)); } } catch (Exception e) { // Save the error in the event metric, as the Proceed() will have faulted stopwatch.Stop(); - task = Task.Run(() => SaveEventMetricAsync(stopwatch.Elapsed, e)); + task = Task.Run(() => SaveEventMetricAsync(stopwatch.Elapsed, activity, e)); // Notify observers of the task of immediate completion TaskStarted?.Invoke(task); @@ -97,7 +100,7 @@ is LogEventMetricAttribute logEventMetricAttribute // Run as a separate task so we do not slow down the method execution // Unless we want the return value, in which case we will not write the metric until the method returns - async Task SaveEventMetricAsync(TimeSpan executionTime, Exception? exception = null) + async Task SaveEventMetricAsync(TimeSpan executionTime, Activity activity, Exception? exception = null) { string methodName = invocation.Method.Name; try @@ -194,6 +197,10 @@ await eventMetricService.SaveEventMetricAsync( // Just log any errors rather than throwing logger.LogError(e, "Error logging event metric for {methodName}", methodName); } + finally + { + activity.Dispose(); + } } } diff --git a/src/SIL.XForge/Services/EventMetricService.cs b/src/SIL.XForge/Services/EventMetricService.cs index 71c8296677..829210f2f2 100644 --- a/src/SIL.XForge/Services/EventMetricService.cs +++ b/src/SIL.XForge/Services/EventMetricService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -93,6 +94,27 @@ public async Task SaveEventMetricAsync( payload[kvp.Key] = GetBsonValue(kvp.Value); } + // Collect tags from Activity.Current and all parent activities + // Child activity tags override parent tags with the same key + Dictionary? tags = null; + var collectedTags = new Dictionary(); + + // Walk up the activity chain collecting tags (child first, so child overrides parent) + var activity = Activity.Current; + while (activity is not null) + { + collectedTags = activity + .Tags.Where(kvp => !collectedTags.ContainsKey(kvp.Key)) + .Union(collectedTags) + .ToDictionary(); + activity = activity.Parent; + } + + if (collectedTags.Count > 0) + { + tags = collectedTags.ToDictionary(kvp => kvp.Key, kvp => GetBsonValue(kvp.Value)); + } + // Generate the event metric var eventMetric = new EventMetric { @@ -103,6 +125,7 @@ public async Task SaveEventMetricAsync( ProjectId = projectId, Scope = scope, UserId = userId, + Tags = tags, }; // Do not set Result if it is null, or the document will contain "result: null" diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index 8c6cc21452..5055f319f0 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -42,8 +42,8 @@ public class MachineApiServiceTests private const string Project01 = "project01"; private const string Project02 = "project02"; private const string Project03 = "project03"; - private const string Build01 = "build01"; - private const string Build02 = "build02"; + private const string ServalBuildId01 = "build01"; + private const string ServalBuildId02 = "build02"; private const string ParallelCorpusId01 = "parallelCorpusId01"; private const string TranslationEngine01 = "translationEngine01"; private const string TrainingDataId01 = "trainingDataId01"; @@ -54,11 +54,14 @@ public class MachineApiServiceTests private const string ParatextUserId01 = "paratextUser01"; private const string Segment = "segment"; private const string TargetSegment = "targetSegment"; - private const string JobId = "jobId"; + private const string HangfireJobId = "jobId"; private const string Data01 = "data01"; - private const string JsonPayload = - """{"event":"TranslationBuildFinished","payload":{"build":{"id":"65f0c455682bb17bc4066917","url":"/api/v1/translation/engines/translationEngine01/builds/65f0c455682bb17bc4066917"},"engine":{"id":"translationEngine01","url":"/api/v1/translation/engines/translationEngine01"},"buildState":"Completed","dateFinished":"2024-03-12T21:14:10.789Z"}}"""; + private readonly string JsonPayload = + """{"event":"TranslationBuildFinished","payload":{"build":{"id":"ServalBuildId01","url":"/api/v1/translation/engines/translationEngine01/builds/ServalBuildId01"},"engine":{"id":"translationEngine01","url":"/api/v1/translation/engines/translationEngine01"},"buildState":"Completed","dateFinished":"2024-03-12T21:14:10.789Z"}}""".Replace( + "ServalBuildId01", + ServalBuildId01 + ); private const string TestUsfm = "\\c 1 \\v 1 Verse 1"; private const string TestUsx = @@ -101,7 +104,7 @@ public class MachineApiServiceTests private static readonly TranslationBuild CompletedTranslationBuild = new TranslationBuild { Url = "https://example.com", - Id = Build01, + Id = ServalBuildId01, Engine = { Id = "engineId", Url = "https://example.com" }, Message = MachineApiService.BuildStateCompleted, Progress = 0, @@ -443,6 +446,7 @@ public async Task BuildCompletedAsync_EventMetricInvalid() { // Set up test environment var env = new TestEnvironment(); + env.SetEmptyDraftGenerationMetricAssociations(); env.EventMetricService.GetEventMetricsAsync(Project01, Arg.Any(), Arg.Any()) .Returns( Task.FromResult( @@ -454,7 +458,7 @@ public async Task BuildCompletedAsync_EventMetricInvalid() { EventType = nameof(MachineProjectService.BuildProjectAsync), ProjectId = Project01, - Result = new BsonString(Build01), + Result = new BsonString(ServalBuildId01), Scope = EventScope.Drafting, UserId = null, }, @@ -467,7 +471,7 @@ public async Task BuildCompletedAsync_EventMetricInvalid() // SUT await env.Service.BuildCompletedAsync( Project01, - Build01, + ServalBuildId01, nameof(JobState.Completed), env.HttpRequestAccessor.SiteRoot ); @@ -479,13 +483,14 @@ public async Task BuildCompletedAsync_EventMetricMissing() { // Set up test environment var env = new TestEnvironment(); + env.SetEmptyDraftGenerationMetricAssociations(); env.EventMetricService.GetEventMetricsAsync(Project01, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(QueryResults.Empty)); // SUT await env.Service.BuildCompletedAsync( Project01, - Build01, + ServalBuildId01, nameof(JobState.Completed), env.HttpRequestAccessor.SiteRoot ); @@ -498,13 +503,14 @@ public async Task BuildCompletedAsync_Exception() // Set up test environment var env = new TestEnvironment(); ServalApiException ex = ServalApiExceptions.Forbidden; + env.SetEmptyDraftGenerationMetricAssociations(); env.EventMetricService.GetEventMetricsAsync(Project01, Arg.Any(), Arg.Any()) .ThrowsAsync(ex); // SUT await env.Service.BuildCompletedAsync( Project01, - Build01, + ServalBuildId01, nameof(JobState.Completed), env.HttpRequestAccessor.SiteRoot ); @@ -517,13 +523,14 @@ public async Task BuildCompletedAsync_Success() { // Set up test environment var env = new TestEnvironment(); + env.SetEmptyDraftGenerationMetricAssociations(); env.EventMetricService.GetEventMetricsAsync(Project01, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(env.GetEventMetricsForBuildCompleted(true))); // SUT await env.Service.BuildCompletedAsync( Project01, - Build01, + ServalBuildId01, nameof(JobState.Completed), env.HttpRequestAccessor.SiteRoot ); @@ -532,7 +539,7 @@ await env .SendBuildCompletedEmailAsync( User01, Project01, - Build01, + ServalBuildId01, nameof(JobState.Completed), env.HttpRequestAccessor.SiteRoot ); @@ -543,13 +550,14 @@ public async Task BuildCompletedAsync_UserDidNotRequestEmail() { // Set up test environment var env = new TestEnvironment(); + env.SetEmptyDraftGenerationMetricAssociations(); env.EventMetricService.GetEventMetricsAsync(Project01, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(env.GetEventMetricsForBuildCompleted(false))); // SUT await env.Service.BuildCompletedAsync( Project01, - Build01, + ServalBuildId01, nameof(JobState.Completed), env.HttpRequestAccessor.SiteRoot ); @@ -564,6 +572,40 @@ await env ); } + [Test] + public async Task BuildCompletedAsync_AddsDraftGenerationRequestIdTag() + { + // Set up test environment + var env = new TestEnvironment(); + const string draftGenerationRequestId = "1234"; + env.SetDraftGenerationMetricAssociation(draftGenerationRequestId); + // Mock for the BuildCompletedAsync email check + env.EventMetricService.GetEventMetricsAsync(Project01, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(env.GetEventMetricsForBuildCompleted(false))); + + System.Diagnostics.Activity? capturedActivity = null; + using (new System.Diagnostics.Activity("TestActivity").Start()) + { + // SUT + await env.Service.BuildCompletedAsync( + Project01, + ServalBuildId01, + nameof(JobState.Completed), + env.HttpRequestAccessor.SiteRoot + ); + + // Capture the activity after the call + capturedActivity = System.Diagnostics.Activity.Current; + } + + // Verify the Activity has the draftGenerationRequestId tag + Assert.IsNotNull(capturedActivity, "Activity.Current should be set during execution"); + Assert.IsTrue( + capturedActivity!.Tags.Any(t => t.Key == "draftGenerationRequestId" && t.Value == draftGenerationRequestId), + "Activity should contain draftGenerationRequestId tag with correct value" + ); + } + [Test] public void CancelPreTranslationBuildAsync_NoPermission() { @@ -627,7 +669,7 @@ public async Task CancelPreTranslationBuildAsync_NoTranslationEngineAndJobQueued env.Service.CancelPreTranslationBuildAsync(User01, Project01, CancellationToken.None) ); - env.BackgroundJobClient.Received(1).ChangeState(JobId, Arg.Any(), null); // Same as Delete() + env.BackgroundJobClient.Received(1).ChangeState(HangfireJobId, Arg.Any(), null); // Same as Delete() Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationQueuedAt); } @@ -639,17 +681,46 @@ public async Task CancelPreTranslationBuildAsync_Success() var env = new TestEnvironment(); await env.QueueBuildAsync(Project01, preTranslate: true, dateTime: DateTime.UtcNow); env.ConfigureTranslationBuild(); - + env.SetEmptyDraftGenerationMetricAssociations(); // SUT string actual = await env.Service.CancelPreTranslationBuildAsync(User01, Project01, CancellationToken.None); - Assert.AreEqual(Build01, actual); + Assert.AreEqual(ServalBuildId01, actual); await env.TranslationEnginesClient.Received(1).CancelBuildAsync(TranslationEngine01, CancellationToken.None); - env.BackgroundJobClient.Received(1).ChangeState(JobId, Arg.Any(), null); // Same as Delete() + env.BackgroundJobClient.Received(1).ChangeState(HangfireJobId, Arg.Any(), null); // Same as Delete() Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationQueuedAt); } + [Test] + public async Task CancelPreTranslationBuildAsync_AddsDraftGenerationRequestIdTag() + { + // Set up test environment + var env = new TestEnvironment(); + await env.QueueBuildAsync(Project01, preTranslate: true, dateTime: DateTime.UtcNow); + env.ConfigureTranslationBuild(); + const string draftGenerationRequestId = "2345"; + env.SetDraftGenerationMetricAssociation(draftGenerationRequestId); + System.Diagnostics.Activity? capturedActivity = null; + using (new System.Diagnostics.Activity("TestActivity").Start()) + { + // SUT + string actual = await env.Service.CancelPreTranslationBuildAsync(User01, Project01, CancellationToken.None); + + // Capture the activity after the call + capturedActivity = System.Diagnostics.Activity.Current; + + Assert.AreEqual(ServalBuildId01, actual); + } + + // Verify the Activity has the draftGenerationRequestId tag + Assert.IsNotNull(capturedActivity, "Activity.Current should be set during execution"); + Assert.IsTrue( + capturedActivity!.Tags.Any(t => t.Key == "draftGenerationRequestId" && t.Value == draftGenerationRequestId), + "Activity should contain draftGenerationRequestId tag with correct value" + ); + } + [Test] public void CalculateSignature_Success() { @@ -657,8 +728,11 @@ public void CalculateSignature_Success() var env = new TestEnvironment(); const string expected = "sha256=8C8E8C11165F748AFC6621F1DB213F79CE52759757D9BD6382C94E92C5B31063"; + const string ExamplePayload = + """{"event":"TranslationBuildFinished","payload":{"build":{"id":"65f0c455682bb17bc4066917","url":"/api/v1/translation/engines/translationEngine01/builds/65f0c455682bb17bc4066917"},"engine":{"id":"translationEngine01","url":"/api/v1/translation/engines/translationEngine01"},"buildState":"Completed","dateFinished":"2024-03-12T21:14:10.789Z"}}"""; + // SUT - string actual = env.Service.CalculateSignature(JsonPayload); + string actual = env.Service.CalculateSignature(ExamplePayload); Assert.AreEqual(expected, actual); } @@ -747,20 +821,49 @@ public async Task ExecuteWebhook_Success() // Set up test environment var env = new TestEnvironment(); string signature = env.Service.CalculateSignature(JsonPayload); - + env.SetEmptyDraftGenerationMetricAssociations(); // SUT await env.Service.ExecuteWebhookAsync(JsonPayload, signature); // Two jobs: BuildCompletedAsync & RetrievePreTranslationStatusAsync env.BackgroundJobClient.Received(2).Create(Arg.Any(), Arg.Any()); } + [Test] + public async Task ExecuteWebhook_RecordsDraftGenerationRequestId() + { + // Set up test environment + var env = new TestEnvironment(); + string signature = env.Service.CalculateSignature(JsonPayload); + const string draftGenerationRequestId = "3456"; + env.SetDraftGenerationMetricAssociation(draftGenerationRequestId); + System.Diagnostics.Activity? capturedActivity = null; + using (new System.Diagnostics.Activity("TestActivity").Start()) + { + // SUT + await env.Service.ExecuteWebhookAsync(JsonPayload, signature); + // Capture the activity after the call + capturedActivity = System.Diagnostics.Activity.Current; + } + // Verify the Activity has the draftGenerationRequestId tag + Assert.IsNotNull(capturedActivity, "Activity.Current should be set during execution"); + Assert.IsTrue( + capturedActivity!.Tags.Any(t => t.Key == "draftGenerationRequestId" && t.Value == draftGenerationRequestId), + "Activity should contain draftGenerationRequestId tag with correct value" + ); + } + [Test] public void GetBuildAsync_BuildEnded() { // Set up test environment var env = new TestEnvironment(); const int minRevision = 0; - env.TranslationEnginesClient.GetBuildAsync(TranslationEngine01, Build01, minRevision, CancellationToken.None) + env.TranslationEnginesClient.GetBuildAsync( + TranslationEngine01, + ServalBuildId01, + minRevision, + CancellationToken.None + ) .Throws(ServalApiExceptions.NotFound); // SUT @@ -768,7 +871,7 @@ public void GetBuildAsync_BuildEnded() env.Service.GetBuildAsync( User01, Project01, - Build01, + ServalBuildId01, minRevision, preTranslate: false, isServalAdmin: false, @@ -782,14 +885,14 @@ public async Task GetBuildAsync_NoBuildRunning() { // Set up test environment var env = new TestEnvironment(); - env.TranslationEnginesClient.GetBuildAsync(TranslationEngine01, Build01, null, CancellationToken.None) + env.TranslationEnginesClient.GetBuildAsync(TranslationEngine01, ServalBuildId01, null, CancellationToken.None) .Throws(ServalApiExceptions.TimeOut); // SUT ServalBuildDto? actual = await env.Service.GetBuildAsync( User01, Project01, - Build01, + ServalBuildId01, minRevision: null, preTranslate: false, isServalAdmin: false, @@ -810,7 +913,7 @@ public void GetBuildAsync_NoPermission() env.Service.GetBuildAsync( User02, Project01, - Build01, + ServalBuildId01, minRevision: null, preTranslate: false, isServalAdmin: false, @@ -830,7 +933,7 @@ public void GetBuildAsync_NoProject() env.Service.GetBuildAsync( User01, "invalid_project_id", - Build01, + ServalBuildId01, minRevision: null, preTranslate: false, isServalAdmin: false, @@ -850,7 +953,7 @@ public void GetBuildAsync_NoTranslationEngine() env.Service.GetBuildAsync( User01, Project03, - Build01, + ServalBuildId01, minRevision: null, preTranslate: false, isServalAdmin: false, @@ -870,7 +973,7 @@ public async Task GetBuildAsync_ServalAdminDoesNotNeedPermission() ServalBuildDto? actual = await env.Service.GetBuildAsync( User02, Project01, - Build01, + ServalBuildId01, minRevision: null, preTranslate: true, isServalAdmin: true, @@ -891,7 +994,7 @@ public async Task GetBuildAsync_Success() ServalBuildDto? actual = await env.Service.GetBuildAsync( User01, Project01, - Build01, + ServalBuildId01, minRevision: null, preTranslate: false, isServalAdmin: false, @@ -1327,7 +1430,7 @@ public async Task GetBuildsAsync_SuccessWithEventMetrics() }, }, ProjectId = Project01, - Result = new BsonString(Build01), + Result = new BsonString(ServalBuildId01), Scope = EventScope.Drafting, }, new EventMetric @@ -1335,7 +1438,7 @@ public async Task GetBuildsAsync_SuccessWithEventMetrics() EventType = nameof(MachineApiService.RetrievePreTranslationStatusAsync), Payload = { { "sfProjectId", Project01 } }, ProjectId = Project01, - Result = new BsonString(Build01), + Result = new BsonString(ServalBuildId01), Scope = EventScope.Drafting, }, ], @@ -1779,7 +1882,7 @@ public async Task GetLastCompletedPreTranslationBuildAsync_NoCompletedBuild() TranslationBuild translationBuild = new TranslationBuild { Url = "https://example.com", - Id = Build01, + Id = ServalBuildId01, Engine = { Id = "engineId", Url = "https://example.com" }, Message = string.Empty, Progress = 0, @@ -1877,7 +1980,7 @@ public async Task GetLastCompletedPreTranslationBuildAsync_RetrievePreTranslatio { // Set up test environment var env = new TestEnvironment(); - const string buildDtoId = $"{Project01}.{Build01}"; + const string buildDtoId = $"{Project01}.{ServalBuildId01}"; const double percentCompleted = 0; const int revision = 43; const JobState state = JobState.Completed; @@ -1888,7 +1991,7 @@ public async Task GetLastCompletedPreTranslationBuildAsync_RetrievePreTranslatio new TranslationBuild { Url = "https://example.com", - Id = Build01, + Id = ServalBuildId01, Engine = new ResourceLink { Id = "engineId", Url = "https://example.com" }, Message = MachineApiService.BuildStateCompleted, Progress = percentCompleted, @@ -1937,7 +2040,7 @@ public async Task GetLastCompletedPreTranslationBuildAsync_RetrievePreTranslatio Assert.AreEqual(revision, actual.Revision); Assert.AreEqual(state.ToString().ToUpperInvariant(), actual.State); Assert.AreEqual(buildDtoId, actual.Id); - Assert.AreEqual(MachineApi.GetBuildHref(Project01, Build01), actual.Href); + Assert.AreEqual(MachineApi.GetBuildHref(Project01, ServalBuildId01), actual.Href); Assert.AreEqual(Project01, actual.Engine.Id); Assert.AreEqual(MachineApi.GetEngineHref(Project01), actual.Engine.Href); } @@ -1950,6 +2053,7 @@ public async Task GetLastCompletedPreTranslationBuildAsync_NoRetrievePreTranslat const double percentCompleted = 0; const int revision = 43; const JobState state = JobState.Completed; + env.SetEmptyDraftGenerationMetricAssociations(); env.TranslationEnginesClient.GetAllBuildsAsync(TranslationEngine01, CancellationToken.None) .Returns( Task.FromResult>( @@ -1957,7 +2061,7 @@ public async Task GetLastCompletedPreTranslationBuildAsync_NoRetrievePreTranslat new TranslationBuild { Url = "https://example.com", - Id = Build01, + Id = ServalBuildId01, Engine = new ResourceLink { Id = "engineId", Url = "https://example.com" }, Message = MachineApiService.BuildStateCompleted, Progress = percentCompleted, @@ -2004,6 +2108,7 @@ public async Task GetLastCompletedPreTranslationBuildAsync_NullScriptureRange_Su const double percentCompleted = 0; const int revision = 43; const JobState state = JobState.Completed; + env.SetEmptyDraftGenerationMetricAssociations(); env.TranslationEnginesClient.GetAllBuildsAsync(TranslationEngine01, CancellationToken.None) .Returns( Task.FromResult>( @@ -2011,7 +2116,7 @@ public async Task GetLastCompletedPreTranslationBuildAsync_NullScriptureRange_Su new TranslationBuild { Url = "https://example.com", - Id = Build01, + Id = ServalBuildId01, Engine = new ResourceLink { Id = "engineId", Url = "https://example.com" }, Message = MachineApiService.BuildStateCompleted, Progress = percentCompleted, @@ -2330,7 +2435,7 @@ public async Task GetPreTranslationRevisionsAsync_NoOps() new TranslationBuild { Url = "https://example.com", - Id = Build01, + Id = ServalBuildId01, Engine = new ResourceLink { Id = "engineId", Url = "https://example.com" }, Message = MachineApiService.BuildStateCompleted, Progress = 0, @@ -2397,7 +2502,7 @@ public async Task GetPreTranslationRevisionsAsync_ServalAdminDoesNotNeedPermissi // Set up test environment var env = new TestEnvironment(); env.SetupEventMetrics("EXO", "GEN", DateTime.UtcNow.AddMinutes(-30)); - string[] buildIds = [Build01, Build02]; + string[] buildIds = [ServalBuildId01, ServalBuildId02]; env.TranslationEnginesClient.GetAllBuildsAsync(Arg.Any(), CancellationToken.None) .Returns( Task.FromResult>( @@ -2450,7 +2555,7 @@ public async Task GetPreTranslationRevisionsAsync_Success() // Set up test environment var env = new TestEnvironment(); env.SetupEventMetrics("EXO", "GEN", DateTime.UtcNow.AddMinutes(-30)); - string[] buildIds = [Build01, Build02]; + string[] buildIds = [ServalBuildId01, ServalBuildId02]; env.TranslationEnginesClient.GetAllBuildsAsync(Arg.Any(), CancellationToken.None) .Returns( Task.FromResult>( @@ -3588,6 +3693,47 @@ public async Task RetrievePreTranslationStatusAsync_UpdatesPreTranslationStatusI await env.PreTranslationService.Received().UpdatePreTranslationStatusAsync(Project01, CancellationToken.None); } + [Test] + public async Task RetrievePreTranslationStatusAsync_SetsDraftGenerationRequestId() + { + // Set up test environment + var env = new TestEnvironment(); + const string draftGenerationRequestId = "1234"; + env.SetDraftGenerationMetricAssociation(draftGenerationRequestId); + env.Service.Configure() + .UpdatePreTranslationTextDocumentsAsync(Project01, CancellationToken.None) + .Returns(Task.CompletedTask); + // Set a previously completed build, which appear to be what SUT queries to get a Serval build id. + env.ConfigureTranslationBuild( + new TranslationBuild + { + Url = "https://example.com", + Id = ServalBuildId01, + Engine = { Id = "engineId", Url = "https://example.com" }, + Message = string.Empty, + Progress = 0, + Revision = 0, + State = JobState.Completed, + } + ); + System.Diagnostics.Activity? capturedActivity = null; + using (new System.Diagnostics.Activity("TestActivity").Start()) + { + // SUT + await env.Service.RetrievePreTranslationStatusAsync(Project01, CancellationToken.None); + + // Capture the activity after the call + capturedActivity = System.Diagnostics.Activity.Current; + } + + // Verify the Activity has the draftGenerationRequestId tag + Assert.IsNotNull(capturedActivity, "Activity.Current should be set during execution"); + Assert.IsTrue( + capturedActivity!.Tags.Any(t => t.Key == "draftGenerationRequestId" && t.Value == draftGenerationRequestId), + "Activity should contain draftGenerationRequestId tag with correct value" + ); + } + [Test] public async Task IsLanguageSupportedAsync_LanguageNotSupported() { @@ -3800,7 +3946,7 @@ public async Task StartBuildAsync_Success() await env.ProjectService.Received(1).SyncAsync(User01, Project01); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project01).ServalData!.TranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project01).ServalData!.TranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project01).ServalData?.TranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData?.TranslationErrorMessage); } @@ -3822,7 +3968,7 @@ await env .SyncService.Received(1) .SyncAsync(Arg.Is(s => s.ProjectId == Project03 && s.TargetOnly && s.UserId == User01)); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationErrorMessage); } @@ -3861,7 +4007,7 @@ await env .SyncService.Received(1) .SyncAsync(Arg.Is(s => s.ProjectId == Project01 && s.TargetOnly && s.UserId == User01)); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationErrorMessage); } @@ -3913,7 +4059,7 @@ await env.Service.StartPreTranslationBuildAsync( await env.ProjectService.Received(1).SyncAsync(User01, Project01); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project01).ServalData?.PreTranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData?.PreTranslationErrorMessage); Assert.IsEmpty(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTrainingScriptureRanges); @@ -3950,7 +4096,7 @@ await env.Service.StartPreTranslationBuildAsync( await env.ProjectService.Received(1).SyncAsync(User01, Project01); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project01).ServalData?.PreTranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData?.PreTranslationErrorMessage); Assert.AreEqual( @@ -4020,7 +4166,7 @@ await env .SyncService.Received(1) .SyncAsync(Arg.Is(s => s.ProjectId == Project01 && s.TargetOnly && s.UserId == User01)); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationErrorMessage); } @@ -4054,7 +4200,7 @@ await env .SyncService.Received(1) .SyncAsync(Arg.Is(s => s.ProjectId == Project01 && s.TargetOnly && s.UserId == User01)); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationErrorMessage); } @@ -4082,7 +4228,7 @@ await env.Service.StartPreTranslationBuildAsync( await env.SyncService.Received(1).SyncAsync(Arg.Any()); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationQueuedAt); project = env.Projects.Get(Project02); @@ -4107,7 +4253,7 @@ await env.Service.StartPreTranslationBuildAsync( await env.SyncService.Received(1).SyncAsync(Arg.Any()); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationQueuedAt); project = env.Projects.Get(Project02); @@ -4149,7 +4295,7 @@ await env.Service.StartPreTranslationBuildAsync( await env.ProjectService.Received(1).SyncAsync(User01, Project02); await env.SyncService.Received(1).SyncAsync(Arg.Any()); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationErrorMessage); } @@ -4190,7 +4336,7 @@ await env.Service.StartPreTranslationBuildAsync( await env.ProjectService.Received(1).SyncAsync(User01, Project02); await env.SyncService.DidNotReceive().SyncAsync(Arg.Any()); env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); - Assert.AreEqual(JobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); + Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project02).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project02).ServalData?.PreTranslationErrorMessage); } @@ -4575,7 +4721,7 @@ private class TestEnvironment public TestEnvironment() { BackgroundJobClient = Substitute.For(); - BackgroundJobClient.Create(Arg.Any(), Arg.Any()).Returns(JobId); + BackgroundJobClient.Create(Arg.Any(), Arg.Any()).Returns(HangfireJobId); DeltaUsxMapper = Substitute.For(); EventMetricService = Substitute.For(); ExceptionHandler = Substitute.For(); @@ -4685,14 +4831,14 @@ public TestEnvironment() .HasRight(Arg.Any(), User01, SFProjectDomain.Drafts, Operation.Create) .Returns(true); ProjectService = Substitute.For(); - ProjectService.SyncAsync(User01, Arg.Any()).Returns(Task.FromResult(JobId)); + ProjectService.SyncAsync(User01, Arg.Any()).Returns(Task.FromResult(HangfireJobId)); RealtimeService = new SFMemoryRealtimeService(); RealtimeService.AddRepository("sf_projects", OTType.Json0, Projects); RealtimeService.AddRepository("text_documents", OTType.Json0, TextDocuments); RealtimeService.AddRepository("texts", OTType.RichText, Texts); ServalOptions = Options.Create(new ServalOptions { WebhookSecret = "this_is_a_secret" }); SyncService = Substitute.For(); - SyncService.SyncAsync(Arg.Any()).Returns(Task.FromResult(JobId)); + SyncService.SyncAsync(Arg.Any()).Returns(Task.FromResult(HangfireJobId)); TranslationEnginesClient = Substitute.For(); TranslationEnginesClient .GetAsync(TranslationEngine01, CancellationToken.None) @@ -4990,7 +5136,7 @@ public TranslationBuild ConfigureTranslationBuild(TranslationBuild? translationB translationBuild ??= new TranslationBuild { Url = "https://example.com", - Id = Build01, + Id = ServalBuildId01, Engine = { Id = "engineId", Url = "https://example.com" }, Message = message, Progress = percentCompleted, @@ -5036,7 +5182,7 @@ public QueryResults GetEventMetricsForBuildCompleted(bool sendEmail }, }, ProjectId = Project01, - Result = new BsonString(Build01), + Result = new BsonString(ServalBuildId01), Scope = EventScope.Drafting, UserId = User01, }, @@ -5057,7 +5203,7 @@ await ProjectSecrets.UpdateAsync( { if (preTranslate) { - u.Set(p => p.ServalData.PreTranslationJobId, JobId); + u.Set(p => p.ServalData.PreTranslationJobId, HangfireJobId); u.Set(p => p.ServalData.PreTranslationQueuedAt, dateTime); u.Set(p => p.ServalData.PreTranslationsRetrieved, preTranslationsRetrieved); if (string.IsNullOrWhiteSpace(errorMessage)) @@ -5071,7 +5217,7 @@ await ProjectSecrets.UpdateAsync( } else { - u.Set(p => p.ServalData.TranslationJobId, JobId); + u.Set(p => p.ServalData.TranslationJobId, HangfireJobId); u.Set(p => p.ServalData.TranslationQueuedAt, dateTime); if (string.IsNullOrWhiteSpace(errorMessage)) { @@ -5133,7 +5279,7 @@ public void SetupEventMetrics( DateTime requestedDateTime ) { - string[] buildIds = [Build01, Build02]; + string[] buildIds = [ServalBuildId01, ServalBuildId02]; EventMetricService .GetEventMetricsAsync(Project01, Arg.Any(), Arg.Any()) .Returns( @@ -5198,5 +5344,55 @@ public static void AssertCoreBuildProperties(TranslationBuild translationBuild, Assert.AreEqual(Project01, actual.Engine.Id); Assert.AreEqual(MachineApi.GetEngineHref(Project01), actual.Engine.Href); } + + public void SetEmptyDraftGenerationMetricAssociations() + { + // Mock for GetEventMetricsAsync in GetDraftGenerationRequestIdForBuildAsync + EventMetricService + .GetEventMetricsAsync( + null, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(Task.FromResult(QueryResults.Empty)); + } + + public void SetDraftGenerationMetricAssociation(string draftGenerationRequestId) + { + // Mock the event metrics service to return a build event with draftGenerationRequestId tag + // This is for GetDraftGenerationRequestIdForBuildAsync + EventMetricService + .GetEventMetricsAsync( + null, + Arg.Is(s => s != null && s.Contains(EventScope.Drafting)), + Arg.Is(t => t.Contains(nameof(Services.MachineProjectService.BuildProjectAsync))), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns( + Task.FromResult( + new QueryResults + { + Results = + [ + new EventMetric + { + EventType = nameof(Services.MachineProjectService.BuildProjectAsync), + Result = ServalBuildId01, + Tags = new Dictionary + { + { "draftGenerationRequestId", draftGenerationRequestId }, + }, + }, + ], + UnpagedCount = 1, + } + ) + ); + } } } diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs index 1ec3f931eb..f05958f595 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs @@ -110,7 +110,13 @@ public async Task BuildProjectForBackgroundJobAsync_DoesNotRecordBuildInProgress ServalApiException ex = ServalApiExceptions.BuildInProgress; var buildConfig = new BuildConfig { ProjectId = Project01 }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // A pre-translation job has been queued @@ -124,6 +130,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: true, + draftGenerationRequestId: null, CancellationToken.None ); @@ -142,7 +149,13 @@ public async Task BuildProjectForBackgroundJobAsync_DoesNotRecordBuildInProgress ServalApiException ex = ServalApiExceptions.BuildInProgress; var buildConfig = new BuildConfig { ProjectId = Project01 }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: false, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: false, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // An SMT translation job has been queued @@ -156,6 +169,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: false, + draftGenerationRequestId: null, CancellationToken.None ); @@ -174,7 +188,13 @@ public async Task BuildProjectForBackgroundJobAsync_DoesNotRecordTaskCancellatio var ex = new TaskCanceledException(); var buildConfig = new BuildConfig { ProjectId = Project01 }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // A pre-translation job has been queued @@ -185,6 +205,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: true, + draftGenerationRequestId: null, CancellationToken.None ); @@ -201,7 +222,13 @@ public async Task BuildProjectForBackgroundJobAsync_DoesNotRecordTaskCancellatio var ex = new TaskCanceledException(); var buildConfig = new BuildConfig { ProjectId = Project01 }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: false, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: false, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // An SMT translation job has been queued @@ -212,6 +239,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: false, + draftGenerationRequestId: null, CancellationToken.None ); @@ -228,7 +256,13 @@ public async Task BuildProjectForBackgroundJobAsync_RecordsDataNotFoundException var ex = new DataNotFoundException("project not found"); var buildConfig = new BuildConfig { ProjectId = Project01 }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // SUT @@ -236,6 +270,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: true, + draftGenerationRequestId: null, CancellationToken.None ); @@ -251,7 +286,13 @@ public async Task BuildProjectForBackgroundJobAsync_MissingDirectoryDataNotFound var ex = new DataNotFoundException("directory not found"); var buildConfig = new BuildConfig { ProjectId = Project01 }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // SUT @@ -259,6 +300,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: true, + draftGenerationRequestId: null, CancellationToken.None ); @@ -273,7 +315,13 @@ public async Task BuildProjectForBackgroundJobAsync_InvalidDataException() var ex = new InvalidDataException("Source project language not specified"); var buildConfig = new BuildConfig { ProjectId = Project01 }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // A pre-translation job has been queued @@ -287,6 +335,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: true, + draftGenerationRequestId: null, CancellationToken.None ); @@ -305,7 +354,13 @@ public async Task BuildProjectForBackgroundJobAsync_RecordsErrors() ServalApiException ex = ServalApiExceptions.Forbidden; var buildConfig = new BuildConfig { ProjectId = Project01 }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // A pre-translation job has been queued @@ -319,6 +374,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: true, + draftGenerationRequestId: null, CancellationToken.None ); @@ -338,7 +394,13 @@ public async Task BuildProjectForBackgroundJobAsync_RecordsErrorsForSmt() ServalApiException ex = ServalApiExceptions.Forbidden; var buildConfig = new BuildConfig { ProjectId = Project01 }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: false, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: false, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // An SMT translation job has been queued @@ -352,6 +414,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: false, + draftGenerationRequestId: null, CancellationToken.None ); @@ -370,7 +433,13 @@ public async Task BuildProjectForBackgroundJobAsync_RunsBuildProjectAsync() var env = new TestEnvironment(); var buildConfig = new BuildConfig { ProjectId = Project01 }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .Returns(Task.FromResult(Build01)); // SUT @@ -378,12 +447,19 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: true, + draftGenerationRequestId: null, CancellationToken.None ); await env .Service.Received(1) - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None); + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ); } [Test] @@ -394,7 +470,13 @@ public async Task BuildProjectForBackgroundJobAsync_SendsEmailForBuildInProgress ServalApiException ex = ServalApiExceptions.BuildInProgress; var buildConfig = new BuildConfig { ProjectId = Project01, SendEmailOnBuildFinished = true }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // A pre-translation job has been queued @@ -408,6 +490,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: true, + draftGenerationRequestId: null, CancellationToken.None ); @@ -422,7 +505,13 @@ public async Task BuildProjectForBackgroundJobAsync_SendsEmailForTaskCancellatio var ex = new TaskCanceledException(); var buildConfig = new BuildConfig { ProjectId = Project01, SendEmailOnBuildFinished = true }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // A pre-translation job has been queued @@ -436,6 +525,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: true, + draftGenerationRequestId: null, CancellationToken.None ); @@ -450,7 +540,13 @@ public async Task BuildProjectForBackgroundJobAsync_SendsEmailForUnexpectedError var ex = new NotSupportedException(); var buildConfig = new BuildConfig { ProjectId = Project01, SendEmailOnBuildFinished = true }; env.Service.Configure() - .BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) .ThrowsAsync(ex); // A pre-translation job has been queued @@ -464,12 +560,56 @@ await env.Service.BuildProjectForBackgroundJobAsync( User01, buildConfig, preTranslate: true, + draftGenerationRequestId: null, CancellationToken.None ); await env.EmailService.Received().SendEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); } + [Test] + public async Task BuildProjectForBackgroundJobAsync_AddsDraftGenerationRequestIdTag() + { + // Set up test environment + var env = new TestEnvironment(); + var buildConfig = new BuildConfig { ProjectId = Project01 }; + const string draftGenerationRequestId = "5678"; + + // Mock BuildProjectAsync to return successfully + env.Service.Configure() + .BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId, + cancellationToken: CancellationToken.None + ) + .Returns(Task.FromResult(Build01)); + + System.Diagnostics.Activity? capturedActivity = null; + using (new System.Diagnostics.Activity("TestActivity").Start()) + { + // SUT + await env.Service.BuildProjectForBackgroundJobAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId, + CancellationToken.None + ); + + // Capture the activity after the call + capturedActivity = System.Diagnostics.Activity.Current; + } + + // Verify the Activity has the draftGenerationRequestId tag + Assert.IsNotNull(capturedActivity, "Activity.Current should be set during execution"); + Assert.IsTrue( + capturedActivity!.Tags.Any(t => t.Key == "draftGenerationRequestId" && t.Value == draftGenerationRequestId), + "Activity should contain draftGenerationRequestId tag with correct value" + ); + } + [Test] public async Task BuildProjectAsync_PreTranslationBuild() { @@ -519,7 +659,13 @@ public async Task BuildProjectAsync_PreTranslationBuild() .Returns(translationBuildConfig); // SUT - await env.Service.BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None); + await env.Service.BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationQueuedAt); await env @@ -567,7 +713,13 @@ public async Task BuildProjectAsync_SmtTranslationBuild() .Returns(Task.FromResult>([])); // SUT - await env.Service.BuildProjectAsync(User01, buildConfig, preTranslate: false, CancellationToken.None); + await env.Service.BuildProjectAsync( + User01, + buildConfig, + preTranslate: false, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.TranslationJobId); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.TranslationQueuedAt); await env @@ -588,6 +740,7 @@ public async Task BuildProjectAsync_ThrowsExceptionWhenProjectMissing() User01, new BuildConfig { ProjectId = Project01 }, preTranslate: false, + draftGenerationRequestId: null, CancellationToken.None ) ); @@ -606,6 +759,7 @@ public async Task BuildProjectAsync_ThrowsExceptionWhenProjectSecretMissing() User01, new BuildConfig { ProjectId = Project01 }, preTranslate: false, + draftGenerationRequestId: null, CancellationToken.None ) ); @@ -623,8 +777,77 @@ public void BuildProjectAsync_ThrowsExceptionWhenSourceProjectMissing() User01, new BuildConfig { ProjectId = Project04 }, preTranslate: false, + draftGenerationRequestId: null, + CancellationToken.None + ) + ); + } + + [Test] + public async Task BuildProjectAsync_CreatesActivityWithDraftGenerationRequestId() + { + // Set up test environment + var env = new TestEnvironment(); + await env.SetupProjectSecretAsync(Project01, new ServalData { PreTranslationEngineId = TranslationEngine01 }); + var buildConfig = new BuildConfig { ProjectId = Project01 }; + const string draftGenerationRequestId = "test-draft-generation-request-id"; + + env.Service.Configure() + .RemoveLegacyServalDataAsync(Project01, preTranslate: true, CancellationToken.None) + .Returns(Task.CompletedTask); + env.Service.Configure() + .EnsureTranslationEngineExistsAsync( + User01, + Arg.Any>(), + Arg.Any(), + preTranslate: true, + useEcho: false, + CancellationToken.None + ) + .Returns(Task.FromResult(TranslationEngine01)); + env.Service.Configure() + .RecreateOrUpdateTranslationEngineIfRequiredAsync( + TranslationEngine01, + Arg.Any(), + preTranslate: true, + useEcho: false, CancellationToken.None ) + .Returns(Task.CompletedTask); + env.Service.Configure() + .SyncProjectCorporaAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .Returns(Task.FromResult>([])); + + System.Diagnostics.Activity? capturedActivity = null; + env.Service.Configure() + .SyncProjectCorporaAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + .Returns(callInfo => + { + // Capture the Activity during execution to verify it has the tag + capturedActivity = System.Diagnostics.Activity.Current; + return Task.FromResult>([]); + }); + + // Create an Activity for the test (simulating what EventMetricLogger would do) + using var activity = new System.Diagnostics.Activity("test-activity"); + activity.Start(); + + // SUT + await env.Service.BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId, + CancellationToken.None + ); + + activity.Stop(); + + // Verify the Activity was created with the draftGenerationRequestId tag + Assert.IsNotNull(capturedActivity, "Activity.Current should have been set during execution"); + Assert.IsTrue( + capturedActivity!.Tags.Any(t => t.Key == "draftGenerationRequestId" && t.Value == draftGenerationRequestId), + "Activity should contain draftGenerationRequestId tag with correct value" ); } @@ -663,7 +886,13 @@ public async Task BuildProjectAsync_ThrowsExceptionWhenServalDataMissing() // SUT Assert.ThrowsAsync(() => - env.Service.BuildProjectAsync(User01, buildConfig, preTranslate: true, CancellationToken.None) + env.Service.BuildProjectAsync( + User01, + buildConfig, + preTranslate: true, + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None + ) ); } @@ -683,7 +912,8 @@ await env.Service.BuildProjectAsync( User01, new BuildConfig { ProjectId = Project01 }, preTranslate: true, - CancellationToken.None + draftGenerationRequestId: null, + cancellationToken: CancellationToken.None ); await env .TranslationEnginesClient.Received() diff --git a/test/SIL.XForge.Tests/Services/EventMetricServiceTests.cs b/test/SIL.XForge.Tests/Services/EventMetricServiceTests.cs index d3c9b1950a..edb08e4826 100644 --- a/test/SIL.XForge.Tests/Services/EventMetricServiceTests.cs +++ b/test/SIL.XForge.Tests/Services/EventMetricServiceTests.cs @@ -393,6 +393,73 @@ public async Task SaveEventMetricAsync_ArraysOfObjects() Assert.AreEqual(BsonNull.Value, eventMetric.Result); } + [Test] + public async Task SaveEventMetricAsync_ActivityTags() + { + var env = new TestEnvironment(); + Dictionary argumentsWithNames = new Dictionary + { + { "projectId", Project01 }, + { "userId", User01 }, + }; + + // Set up Activity tags + using var activity = new System.Diagnostics.Activity("test"); + activity.AddTag("draftGenerationRequestId", "abc-123-def-456"); + activity.AddTag("someOtherThing", "xyz-789"); + activity.Start(); + + // SUT + await env.Service.SaveEventMetricAsync(Project01, User01, EventType01, EventScope01, argumentsWithNames); + + activity.Stop(); + + EventMetric eventMetric = env.EventMetrics.Query().OrderByDescending(e => e.TimeStamp).First(); + Assert.IsNotNull(eventMetric.Tags); + Assert.AreEqual(2, eventMetric.Tags.Count); + Assert.AreEqual("abc-123-def-456", eventMetric.Tags["draftGenerationRequestId"]?.AsString); + Assert.AreEqual("xyz-789", eventMetric.Tags["someOtherThing"]?.AsString); + } + + [Test] + public async Task SaveEventMetricAsync_ActivityTags_FromParentActivity() + { + var env = new TestEnvironment(); + Dictionary argumentsWithNames = new Dictionary { { "projectId", Project01 } }; + + // Set up parent activity with tags + using var parentActivity = new System.Diagnostics.Activity("parent"); + parentActivity.AddTag("draftGenerationRequestId", "parent-123"); + parentActivity.AddTag("parentTag", "parent-value"); + parentActivity.Start(); + + // Create child activity (simulating what EventMetricLogger does) + using var childActivity = new System.Diagnostics.Activity("child"); + childActivity.AddTag("childTag", "child-value"); + childActivity.AddTag("draftGenerationRequestId", "child-override-456"); // Override parent + childActivity.Start(); + + // SUT - should collect tags from both parent and child + await env.Service.SaveEventMetricAsync(Project01, User01, EventType01, EventScope01, argumentsWithNames); + + childActivity.Stop(); + parentActivity.Stop(); + + // Verify the saved event metric + EventMetric eventMetric = env.EventMetrics.Query().OrderByDescending(e => e.TimeStamp).First(); + Assert.IsNotNull(eventMetric.Tags); + Assert.AreEqual(3, eventMetric.Tags.Count); + + // Child tag should be present + Assert.AreEqual("child-value", eventMetric.Tags["childTag"]?.AsString); + + // Parent tag should be present + Assert.AreEqual("parent-value", eventMetric.Tags["parentTag"]?.AsString); + + // Child should override parent for same key + Assert.AreEqual("child-override-456", eventMetric.Tags["draftGenerationRequestId"]?.AsString); + } + private class TestEnvironment { public TestEnvironment()