diff --git a/Backend.Tests/Backend.Tests.csproj b/Backend.Tests/Backend.Tests.csproj index 043a93624a..6436393184 100644 --- a/Backend.Tests/Backend.Tests.csproj +++ b/Backend.Tests/Backend.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/Backend.Tests/Mocks/LocationProviderMock.cs b/Backend.Tests/Mocks/LocationProviderMock.cs new file mode 100644 index 0000000000..b894e5ea80 --- /dev/null +++ b/Backend.Tests/Mocks/LocationProviderMock.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using BackendFramework.Interfaces; +using BackendFramework.Otel; + +namespace Backend.Tests.Mocks +{ + sealed internal class LocationProviderMock : ILocationProvider + { + public Task GetLocation() + { + LocationApi location = new LocationApi + { + Country = "test country", + RegionName = "test region", + City = "city" + }; + return Task.FromResult(location); + } + } +} diff --git a/Backend.Tests/Otel/LocationProviderTests.cs b/Backend.Tests/Otel/LocationProviderTests.cs new file mode 100644 index 0000000000..64f333529a --- /dev/null +++ b/Backend.Tests/Otel/LocationProviderTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BackendFramework.Otel; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Moq.Protected; +using NUnit.Framework; + +namespace Backend.Tests.Otel +{ + public class LocationProviderTests + { + private readonly IPAddress TestIpAddress = new(new byte[] { 100, 0, 0, 0 }); + private IHttpContextAccessor _contextAccessor = null!; + private IMemoryCache _memoryCache = null!; + private Mock _handlerMock = null!; + private Mock _httpClientFactory = null!; + private LocationProvider _locationProvider = null!; + + [SetUp] + public void Setup() + { + // Set up HttpContextAccessor with mocked IP + _contextAccessor = new HttpContextAccessor(); + var httpContext = new DefaultHttpContext() + { + Connection = + { + RemoteIpAddress = TestIpAddress + } + }; + _contextAccessor.HttpContext = httpContext; + + // Set up MemoryCache + var services = new ServiceCollection(); + services.AddMemoryCache(); + var serviceProvider = services.BuildServiceProvider(); + _memoryCache = serviceProvider.GetService()!; + + var result = new HttpResponseMessage() + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{}") + }; + + // Set up HttpClientFactory mock using httpClient with mocked HttpMessageHandler + _handlerMock = new Mock(); + _handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(result) + .Verifiable(); + + var httpClient = new HttpClient(_handlerMock.Object); + _httpClientFactory = new Mock(); + _httpClientFactory + .Setup(x => x.CreateClient(It.IsAny())) + .Returns(httpClient); + + _locationProvider = new LocationProvider(_contextAccessor, _memoryCache, _httpClientFactory.Object); + } + + public static void Verify(Mock mock, Func match) + { + mock.Protected() + .Verify( + "SendAsync", + Times.Exactly(1), + ItExpr.Is(req => match(req)), + ItExpr.IsAny() + ); + } + + [Test] + public async Task GetLocationHttpClientUsesIp() + { + // Act + await _locationProvider.GetLocationFromIp(TestIpAddress.ToString()); + + // Assert + Verify(_handlerMock, r => r.RequestUri!.AbsoluteUri.Contains(TestIpAddress.ToString())); + Verify(_handlerMock, r => !r.RequestUri!.AbsoluteUri.Contains("123.1.2.3")); + } + + [Test] + public async Task GetLocationUsesHttpContextIp() + { + // Act + await _locationProvider.GetLocation(); + + // Assert + Verify(_handlerMock, r => r.RequestUri!.AbsoluteUri.Contains(TestIpAddress.ToString())); + Verify(_handlerMock, r => !r.RequestUri!.AbsoluteUri.Contains("123.1.2.3")); + } + + [Test] + public async Task GetLocationUsesCache() + { + // Act + // call getLocation twice and verify async method is called only once + await _locationProvider.GetLocation(); + await _locationProvider.GetLocation(); + + // Assert + Verify(_handlerMock, r => r.RequestUri!.AbsoluteUri.Contains(TestIpAddress.ToString())); + } + } +} diff --git a/Backend.Tests/Otel/OtelKernelTests.cs b/Backend.Tests/Otel/OtelKernelTests.cs new file mode 100644 index 0000000000..d21a66f663 --- /dev/null +++ b/Backend.Tests/Otel/OtelKernelTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Backend.Tests.Mocks; +using BackendFramework.Otel; +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using static BackendFramework.Otel.OtelKernel; + +namespace Backend.Tests.Otel +{ + public class OtelKernelTests : IDisposable + { + private const string FrontendSessionIdKey = "sessionId"; + private const string OtelSessionIdKey = "sessionId"; + private const string OtelSessionBaggageKey = "sessionBaggage"; + private LocationEnricher _locationEnricher = null!; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _locationEnricher?.Dispose(); + } + } + + [Test] + public void BuildersSetSessionBaggageFromHeader() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers[FrontendSessionIdKey] = "123"; + var activity = new Activity("testActivity").Start(); + + // Act + TrackSession(activity, httpContext.Request); + + // Assert + Assert.That(activity.Baggage.Any(_ => _.Key == OtelSessionBaggageKey)); + } + + [Test] + public void OnEndSetsSessionTagFromBaggage() + { + // Arrange + var activity = new Activity("testActivity").Start(); + activity.SetBaggage(OtelSessionBaggageKey, "test session id"); + + // Act + _locationEnricher.OnEnd(activity); + + // Assert + Assert.That(activity.Tags.Any(_ => _.Key == OtelSessionIdKey)); + } + + + [Test] + public void OnEndSetsLocationTags() + { + // Arrange + _locationEnricher = new LocationEnricher(new LocationProviderMock()); + var activity = new Activity("testActivity").Start(); + + // Act + _locationEnricher.OnEnd(activity); + + // Assert + var testLocation = new Dictionary + { + {"country", "test country"}, + {"regionName", "test region"}, + {"city", "city"} + }; + Assert.That(activity.Tags, Is.SupersetOf(testLocation)); + } + + public void OnEndRedactsIp() + { + // Arrange + _locationEnricher = new LocationEnricher(new LocationProviderMock()); + var activity = new Activity("testActivity").Start(); + activity.SetTag("url.full", $"{LocationProvider.locationGetterUri}100.0.0.0"); + + // Act + _locationEnricher.OnEnd(activity); + + // Assert + Assert.That(activity.Tags.Any(_ => _.Key == "url.full" && _.Value == "")); + Assert.That(activity.Tags.Any(_ => _.Key == "url.redacted.ip" && _.Value == LocationProvider.locationGetterUri)); + } + } +} diff --git a/Backend.Tests/Otel/OtelServiceTests.cs b/Backend.Tests/Otel/OtelServiceTests.cs new file mode 100644 index 0000000000..e3b3180234 --- /dev/null +++ b/Backend.Tests/Otel/OtelServiceTests.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; +using BackendFramework.Otel; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace Backend.Tests.Otel +{ + public class OtelServiceTests + { + [Test] + public static void TestStartActivityWithTag() + { + // Arrange + var services = new ServiceCollection(); + services.AddOpenTelemetryInstrumentation(); + AddActivityListener(); + + // Act + var activity = OtelService.StartActivityWithTag("test key", "test val"); + var tag = activity?.GetTagItem("test key"); + var wrongTag = activity?.GetTagItem("wrong key"); + + // Assert + Assert.That(activity, Is.Not.Null); + Assert.That(tag, Is.Not.Null); + Assert.That(wrongTag, Is.Null); + } + + private static void AddActivityListener() + { + var activityListener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(activityListener); + } + } +} diff --git a/Backend/BackendFramework.csproj b/Backend/BackendFramework.csproj index 10e9073299..0fc805a80b 100644 --- a/Backend/BackendFramework.csproj +++ b/Backend/BackendFramework.csproj @@ -1,7 +1,7 @@ net8.0 - 10.0 + 12.0 enable true Recommended @@ -10,7 +10,13 @@ $(NoWarn);CA1305;CA1848;CS1591 - + + + + + + + NU1701 diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index dc2219c95d..682196430e 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using BackendFramework.Interfaces; using BackendFramework.Models; +using BackendFramework.Otel; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -19,6 +20,8 @@ public class WordController : Controller private readonly IPermissionService _permissionService; private readonly IWordService _wordService; + private const string otelTagName = "otel.WordController"; + public WordController(IWordRepository repo, IWordService wordService, IProjectRepository projRepo, IPermissionService permissionService) { @@ -33,6 +36,8 @@ public WordController(IWordRepository repo, IWordService wordService, IProjectRe [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] public async Task DeleteFrontierWord(string projectId, string wordId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word from Frontier"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -56,6 +61,8 @@ public async Task DeleteFrontierWord(string projectId, string wor [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] public async Task GetProjectWords(string projectId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting all words"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -73,6 +80,8 @@ public async Task GetProjectWords(string projectId) [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Word))] public async Task GetWord(string projectId, string wordId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting a word"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -95,6 +104,8 @@ public async Task GetWord(string projectId, string wordId) [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))] public async Task IsFrontierNonempty(string projectId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "checking if Frontier is nonempty"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -112,6 +123,8 @@ public async Task IsFrontierNonempty(string projectId) [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] public async Task GetProjectFrontierWords(string projectId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting all Frontier words"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -129,6 +142,8 @@ public async Task GetProjectFrontierWords(string projectId) [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))] public async Task IsInFrontier(string projectId, string wordId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "checking if Frontier contains a word"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -146,6 +161,8 @@ public async Task IsInFrontier(string projectId, string wordId) [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] public async Task AreInFrontier(string projectId, [FromBody, BindRequired] List wordIds) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "checking if Frontier contains given words"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -175,6 +192,8 @@ public async Task AreInFrontier(string projectId, [FromBody, Bind [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] public async Task GetDuplicateId(string projectId, [FromBody, BindRequired] Word word) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "checking for duplicates of a word"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -196,6 +215,8 @@ public async Task GetDuplicateId(string projectId, [FromBody, Bin public async Task UpdateDuplicate( string projectId, string dupId, [FromBody, BindRequired] Word word) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "combining duplicate words"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -230,6 +251,8 @@ public async Task UpdateDuplicate( [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] public async Task CreateWord(string projectId, [FromBody, BindRequired] Word word) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "creating a word"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -251,6 +274,8 @@ public async Task CreateWord(string projectId, [FromBody, BindReq public async Task UpdateWord( string projectId, string wordId, [FromBody, BindRequired] Word word) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "updating words"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); @@ -280,6 +305,8 @@ public async Task UpdateWord( public async Task RevertWords( string projectId, [FromBody, BindRequired] Dictionary wordIds) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "reverting words"); + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { return Forbid(); diff --git a/Backend/Interfaces/ILocationProvider.cs b/Backend/Interfaces/ILocationProvider.cs new file mode 100644 index 0000000000..d4a0c89e24 --- /dev/null +++ b/Backend/Interfaces/ILocationProvider.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using BackendFramework.Otel; + +namespace BackendFramework.Interfaces +{ + public interface ILocationProvider + { + Task GetLocation(); + } +} diff --git a/Backend/Otel/LocationApi.cs b/Backend/Otel/LocationApi.cs new file mode 100644 index 0000000000..8c3e8c427e --- /dev/null +++ b/Backend/Otel/LocationApi.cs @@ -0,0 +1,8 @@ +namespace BackendFramework.Otel; + +public class LocationApi +{ + public string? Country { get; set; } + public string? RegionName { get; set; } + public string? City { get; set; } +} diff --git a/Backend/Otel/LocationProvider.cs b/Backend/Otel/LocationProvider.cs new file mode 100644 index 0000000000..bd53870bac --- /dev/null +++ b/Backend/Otel/LocationProvider.cs @@ -0,0 +1,61 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using BackendFramework.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; + +namespace BackendFramework.Otel +{ + public class LocationProvider : ILocationProvider + { + public const string locationGetterUri = "http://ip-api.com/json/"; + private readonly IHttpContextAccessor _contextAccessor; + private readonly IMemoryCache _memoryCache; + private readonly IHttpClientFactory _httpClientFactory; + public LocationProvider(IHttpContextAccessor contextAccessor, IMemoryCache memoryCache, IHttpClientFactory httpClientFactory) + { + _contextAccessor = contextAccessor; + _memoryCache = memoryCache; + _httpClientFactory = httpClientFactory; + } + + public async Task GetLocation() + { + // note: adding any activity tags in this function will cause overflow + // because OtelKernel calls the function for each activity + if (_contextAccessor.HttpContext is { } context) + { + var ipAddress = context.GetServerVariable("HTTP_X_FORWARDED_FOR") ?? context.Connection.RemoteIpAddress?.ToString(); + var ipAddressWithoutPort = ipAddress?.Split(':')[0]; + + return await _memoryCache.GetOrCreateAsync( + "location_" + ipAddressWithoutPort, + async (cacheEntry) => + { + cacheEntry.SlidingExpiration = TimeSpan.FromHours(1); + try + { + return await GetLocationFromIp(ipAddressWithoutPort); + } + catch + { + // TODO consider what to have in catch + Console.WriteLine("Attempted to get location but exception"); + throw; + } + } + ); + } + return null; + } + + internal async Task GetLocationFromIp(string? ipAddressWithoutPort) + { + var route = locationGetterUri + $"{ipAddressWithoutPort}"; + var httpClient = _httpClientFactory.CreateClient(); + return await httpClient.GetFromJsonAsync(route); + } + } +} diff --git a/Backend/Otel/OtelKernel.cs b/Backend/Otel/OtelKernel.cs new file mode 100644 index 0000000000..74842e3da1 --- /dev/null +++ b/Backend/Otel/OtelKernel.cs @@ -0,0 +1,122 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net.Http; +using BackendFramework.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry; +using OpenTelemetry.Instrumentation.AspNetCore; +using OpenTelemetry.Instrumentation.Http; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace BackendFramework.Otel +{ + public static class OtelKernel + { + public const string SourceName = "Backend-Otel"; + + public static void AddOpenTelemetryInstrumentation(this IServiceCollection services) + { + var appResourceBuilder = ResourceBuilder.CreateDefault(); + services.AddOpenTelemetry().WithTracing(tracerProviderBuilder => + tracerProviderBuilder + .SetResourceBuilder(appResourceBuilder) + .AddSource(SourceName) + .AddProcessor() + .AddAspNetCoreInstrumentation(AspNetCoreBuilder) + .AddHttpClientInstrumentation(HttpClientBuilder) + .AddConsoleExporter() + .AddOtlpExporter() + ); + } + + internal static void TrackSession(Activity activity, HttpRequest request) + { + var sessionId = request.Headers.TryGetValue("sessionId", out var values) ? values.FirstOrDefault() : null; + if (sessionId is not null) + { + activity.SetBaggage("sessionBaggage", sessionId); + } + } + + internal static void GetContentLengthAspNet(Activity activity, IHeaderDictionary headers, string label) + { + var contentLength = headers.ContentLength; + if (contentLength.HasValue) + { + activity.SetTag(label, contentLength.Value); + } + } + + internal static void GetContentLengthHttp(Activity activity, HttpContent? content, string label) + { + var contentLength = content?.Headers.ContentLength; + if (contentLength.HasValue) + { + activity.SetTag(label, contentLength.Value); + } + } + + [ExcludeFromCodeCoverage] + private static void AspNetCoreBuilder(AspNetCoreTraceInstrumentationOptions options) + { + options.RecordException = true; + options.EnrichWithHttpRequest = (activity, request) => + { + GetContentLengthAspNet(activity, request.Headers, "inbound.http.request.body.size"); + TrackSession(activity, request); + }; + options.EnrichWithHttpResponse = (activity, response) => + { + GetContentLengthAspNet(activity, response.Headers, "inbound.http.response.body.size"); + }; + } + [ExcludeFromCodeCoverage] + private static void HttpClientBuilder(HttpClientTraceInstrumentationOptions options) + { + options.EnrichWithHttpRequestMessage = (activity, request) => + { + GetContentLengthHttp(activity, request.Content, "outbound.http.request.body.size"); + if (request.RequestUri is not null) + { + if (!string.IsNullOrEmpty(request.RequestUri.Query)) + { + activity.SetTag("url.query", request.RequestUri.Query); + } + } + }; + options.EnrichWithHttpResponseMessage = (activity, response) => + { + GetContentLengthHttp(activity, response.Content, "outbound.http.response.body.size"); + }; + } + + internal class LocationEnricher(ILocationProvider locationProvider) : BaseProcessor + { + public override async void OnEnd(Activity data) + { + var uriPath = (string?)data.GetTagItem("url.full"); + var locationUri = LocationProvider.locationGetterUri; + if (uriPath is null || !uriPath.Contains(locationUri)) + { + var location = await locationProvider.GetLocation(); + data?.AddTag("country", location?.Country); + data?.AddTag("regionName", location?.RegionName); + data?.AddTag("city", location?.City); + } + data?.SetTag("sessionId", data?.GetBaggageItem("sessionBaggage")); + if (uriPath is not null && uriPath.Contains(locationUri)) + { + // When getting location externally, url.full includes site URI and user IP. + // In such cases, only add url without IP information to traces. + data?.SetTag("url.full", ""); + data?.SetTag("url.redacted.ip", LocationProvider.locationGetterUri); + } + } + } + } +} + diff --git a/Backend/Otel/OtelService.cs b/Backend/Otel/OtelService.cs new file mode 100644 index 0000000000..386325cc03 --- /dev/null +++ b/Backend/Otel/OtelService.cs @@ -0,0 +1,19 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace BackendFramework.Otel +{ + public class OtelService + { + /// + /// Start an Open Telemetry activity and add a tag with given key and value. + /// To trace a method, call this at the start of the method with `using`, e.g.: + /// using var activity = OtelService.StartActivityWithTag("tag key", "value of the tag"); + /// + public static Activity? StartActivityWithTag( + string key, object? value, [CallerMemberName] string activityName = "") + { + return new ActivitySource(OtelKernel.SourceName).StartActivity(activityName)?.AddTag(key, value); + } + } +} diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 18fcdf2803..7a2f163129 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -5,6 +5,7 @@ using BackendFramework.Helper; using BackendFramework.Interfaces; using BackendFramework.Models; +using BackendFramework.Otel; using MongoDB.Driver; namespace BackendFramework.Repositories @@ -15,6 +16,8 @@ public class WordRepository : IWordRepository { private readonly IWordContext _wordDatabase; + private const string otelTagName = "otel.WordRepository"; + public WordRepository(IWordContext collectionSettings) { _wordDatabase = collectionSettings; @@ -50,12 +53,16 @@ private static FilterDefinition GetProjectWordsFilter(string projectId, Li /// Finds all s with specified projectId public async Task> GetAllWords(string projectId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting all words"); + return await _wordDatabase.Words.Find(GetAllProjectWordsFilter(projectId)).ToListAsync(); } /// Finds with specified wordId and projectId public async Task GetWord(string projectId, string wordId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting a word"); + var wordList = await _wordDatabase.Words.FindAsync(GetProjectWordFilter(projectId, wordId)); try { @@ -72,6 +79,8 @@ public async Task> GetAllWords(string projectId) /// A bool: success of operation public async Task DeleteAllWords(string projectId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting all words from WordsCollection and Frontier"); + var filterDef = new FilterDefinitionBuilder(); var filter = filterDef.Eq(x => x.ProjectId, projectId); @@ -105,6 +114,8 @@ private static void PopulateBlankWordTimes(Word word) /// The word created public async Task Create(Word word) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "creating a word in WordsCollection and Frontier"); + PopulateBlankWordTimes(word); await _wordDatabase.Words.InsertOneAsync(word); await AddFrontier(word); @@ -120,6 +131,8 @@ public async Task Create(Word word) /// The words created public async Task> Create(List words) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "creating words in WordsCollection and Frontier"); + if (words.Count == 0) { return words; @@ -141,6 +154,8 @@ public async Task> Create(List words) /// The word created public async Task Add(Word word) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "adding a word to WordsCollection"); + PopulateBlankWordTimes(word); await _wordDatabase.Words.InsertOneAsync(word); return word; @@ -149,6 +164,8 @@ public async Task Add(Word word) /// Checks if Frontier is nonempty for specified public async Task IsFrontierNonempty(string projectId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "checking if Frontier is nonempty"); + var word = await _wordDatabase.Frontier.Find(GetAllProjectWordsFilter(projectId)).FirstOrDefaultAsync(); return word is not null; } @@ -156,18 +173,24 @@ public async Task IsFrontierNonempty(string projectId) /// Checks if specified word is in Frontier for specified public async Task IsInFrontier(string projectId, string wordId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "checking if Frontier contains a word"); + return (await _wordDatabase.Frontier.CountDocumentsAsync(GetProjectWordFilter(projectId, wordId))) > 0; } /// Finds all s in the Frontier for specified public async Task> GetFrontier(string projectId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting all Frontier words"); + return await _wordDatabase.Frontier.Find(GetAllProjectWordsFilter(projectId)).ToListAsync(); } /// Finds all s in Frontier of specified project with specified vern public async Task> GetFrontierWithVernacular(string projectId, string vernacular) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting all words from Frontier with vern"); + return await _wordDatabase.Frontier.Find(GetAllProjectWordsFilter(projectId, vernacular)).ToListAsync(); } @@ -176,6 +199,8 @@ public async Task> GetFrontierWithVernacular(string projectId, string /// The word created public async Task AddFrontier(Word word) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "adding a word to Frontier"); + await _wordDatabase.Frontier.InsertOneAsync(word); return word; } @@ -185,6 +210,8 @@ public async Task AddFrontier(Word word) /// The words created public async Task> AddFrontier(List words) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "adding words to Frontier"); + await _wordDatabase.Frontier.InsertManyAsync(words); return words; } @@ -193,6 +220,8 @@ public async Task> AddFrontier(List words) /// A bool: success of operation public async Task DeleteFrontier(string projectId, string wordId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word from Frontier"); + var deleted = await _wordDatabase.Frontier.DeleteOneAsync(GetProjectWordFilter(projectId, wordId)); return deleted.DeletedCount > 0; } @@ -201,6 +230,8 @@ public async Task DeleteFrontier(string projectId, string wordId) /// Number of words deleted public async Task DeleteFrontier(string projectId, List wordIds) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting words from Frontier"); + var deleted = await _wordDatabase.Frontier.DeleteManyAsync(GetProjectWordsFilter(projectId, wordIds)); return deleted.DeletedCount; } diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 60b732dc08..9ada4cf6ed 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using BackendFramework.Interfaces; using BackendFramework.Models; +using BackendFramework.Otel; namespace BackendFramework.Services { @@ -11,6 +12,8 @@ public class WordService : IWordService { private readonly IWordRepository _wordRepo; + private const string otelTagName = "otel.WordService"; + public WordService(IWordRepository wordRepo) { _wordRepo = wordRepo; @@ -35,6 +38,8 @@ private static Word PrepEditedData(string userId, Word word) /// The created word public async Task Create(string userId, Word word) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "creating a word"); + return await _wordRepo.Create(PrepEditedData(userId, word)); } @@ -42,6 +47,8 @@ public async Task Create(string userId, Word word) /// The created word public async Task> Create(string userId, List words) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "creating words"); + return await _wordRepo.Create(words.Select(w => PrepEditedData(userId, w)).ToList()); } @@ -56,6 +63,8 @@ private async Task Add(string userId, Word word) /// A bool: success of operation public async Task Delete(string projectId, string userId, string wordId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word"); + var wordIsInFrontier = await _wordRepo.DeleteFrontier(projectId, wordId); // We only want to add the deleted word if the word started in the frontier. @@ -88,6 +97,8 @@ public async Task Delete(string projectId, string userId, string wordId) /// New word public async Task Delete(string projectId, string userId, string wordId, string fileName) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting an audio"); + var wordWithAudioToDelete = await _wordRepo.GetWord(projectId, wordId); if (wordWithAudioToDelete is null) { @@ -116,6 +127,8 @@ public async Task Delete(string projectId, string userId, string wordId) /// A string: id of new word public async Task DeleteFrontierWord(string projectId, string userId, string wordId) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word from Frontier"); + var wordIsInFrontier = await _wordRepo.DeleteFrontier(projectId, wordId); if (!wordIsInFrontier) { @@ -139,6 +152,8 @@ public async Task Delete(string projectId, string userId, string wordId) /// A bool: true if successful, false if any don't exist or are already in the Frontier. public async Task RestoreFrontierWords(string projectId, List wordIds) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring words to Frontier"); + var words = new List(); foreach (var id in wordIds) { @@ -157,6 +172,8 @@ public async Task RestoreFrontierWords(string projectId, List word /// A bool: success of operation public async Task Update(string projectId, string userId, string wordId, Word word) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "updating a word in Frontier"); + var wordIsInFrontier = await _wordRepo.DeleteFrontier(projectId, wordId); // We only want to update words that are in the frontier @@ -177,6 +194,8 @@ public async Task Update(string projectId, string userId, string wordId, W /// The id string of the existing word, or null if none. public async Task FindContainingWord(Word word) { + using var activity = OtelService.StartActivityWithTag(otelTagName, "checking for duplicates of a word"); + var wordsWithVern = await _wordRepo.GetFrontierWithVernacular(word.ProjectId, word.Vernacular); var duplicatedWord = wordsWithVern.Find(w => w.Contains(word)); return duplicatedWord?.Id; diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 8bdad6da05..708bf293b0 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -5,6 +5,7 @@ using BackendFramework.Helper; using BackendFramework.Interfaces; using BackendFramework.Models; +using BackendFramework.Otel; using BackendFramework.Repositories; using BackendFramework.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -287,6 +288,13 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + + // OpenTelemetry + services.AddHttpClient(); + services.AddMemoryCache(); + services.AddHttpContextAccessor(); + services.AddTransient(); + services.AddOpenTelemetryInstrumentation(); } /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/deploy/helm/thecombine/charts/backend/templates/_helpers.tpl b/deploy/helm/thecombine/charts/backend/templates/_helpers.tpl index afb59de162..3d9ac44ce3 100644 --- a/deploy/helm/thecombine/charts/backend/templates/_helpers.tpl +++ b/deploy/helm/thecombine/charts/backend/templates/_helpers.tpl @@ -10,3 +10,16 @@ {{- printf "%s:%s" .Values.imageName .Values.global.imageTag }} {{- end }} {{- end }} + +{{/* Build OTEL service name based on target */}} +{{- define "backend.otelServiceName" -}} + {{- if eq .Values.global.serverName "thecombine.localhost" }} + {{- print "dev" }} + {{- else if eq .Values.global.serverName "qa-kube.thecombine.app" }} + {{- print "dev" }} + {{- else if eq .Values.global.serverName "thecombine.app" }} + {{- print "prod" }} + {{- else }} + {{- printf "%s" .Values.global.serverName}} + {{- end }} +{{- end }} diff --git a/deploy/helm/thecombine/charts/backend/templates/deployment-backend.yaml b/deploy/helm/thecombine/charts/backend/templates/deployment-backend.yaml index fbdf68418b..bcfdc71c3a 100644 --- a/deploy/helm/thecombine/charts/backend/templates/deployment-backend.yaml +++ b/deploy/helm/thecombine/charts/backend/templates/deployment-backend.yaml @@ -29,6 +29,12 @@ spec: image: {{ include "backend.containerImage" . }} imagePullPolicy: {{ .Values.global.imagePullPolicy }} env: + - name: OTEL_SERVICE_NAME + value: {{ include "backend.otelServiceName" . }} + - name: OTEL_EXPORTER_OTLP_PROTOCOL + value: "http/protobuf" + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "http://otel-opentelemetry-collector:4318" - name: COMBINE_CAPTCHA_REQUIRED valueFrom: configMapKeyRef: diff --git a/deploy/scripts/app_release.py b/deploy/scripts/app_release.py index 604cd369fb..d2b34a9d05 100755 --- a/deploy/scripts/app_release.py +++ b/deploy/scripts/app_release.py @@ -31,7 +31,7 @@ def get_release() -> str: num_commits = match[2] # Get the branch name result = run_cmd(["git", "branch", "--show-current"], chomp=True) - branch_name = re.sub("_+", "-", result.stdout) + branch_name = re.sub("[/_]+", "-", result.stdout) return f"{release_string}-{branch_name}.{num_commits}" message = f"Unrecognized release value in tag: {result.stdout}" raise ValueError(message) diff --git a/deploy/scripts/setup_cluster.py b/deploy/scripts/setup_cluster.py index 0342a7f633..9faaa89fb8 100755 --- a/deploy/scripts/setup_cluster.py +++ b/deploy/scripts/setup_cluster.py @@ -108,6 +108,9 @@ def main() -> None: for chart in yaml.safe_load(chart_list_results.stdout): curr_charts.append(chart["name"]) + # Add the current script directory to the OS Environment variables + os.environ["SCRIPTS_DIR"] = str(scripts_dir) + # Verify the Kubernetes/Helm environment kube_env = KubernetesEnvironment(args) # Install/upgrade the required charts @@ -162,8 +165,11 @@ def main() -> None: with open(override_file, "w") as file: yaml.dump(chart_spec["override"], file) helm_cmd.extend(["-f", str(override_file)]) + if "additional_args" in chart_spec: + for arg in chart_spec["additional_args"]: + helm_cmd.append(arg.format(**os.environ)) helm_cmd_str = " ".join(helm_cmd) - logging.info(f"Running: {helm_cmd_str}") + logging.debug(f"Running: {helm_cmd_str}") # Run with os.system so that there is feedback on stdout/stderr while the # command is running exit_status = os.waitstatus_to_exitcode(os.system(helm_cmd_str)) diff --git a/deploy/scripts/setup_files/cluster_config.yaml b/deploy/scripts/setup_files/cluster_config.yaml index 6f383e1103..267f255e0b 100644 --- a/deploy/scripts/setup_files/cluster_config.yaml +++ b/deploy/scripts/setup_files/cluster_config.yaml @@ -5,6 +5,7 @@ clusters: development: - cert-manager - nginx-ingress-controller + - otel rancher: - rancher-ui cert-manager: @@ -15,6 +16,26 @@ clusters: # Specify how each chart is to be installed. The "repo" key specified which # helm repository needs to be added and the "chart" key specifies how to # install/update the chart. + +otel: + repo: + name: open-telemetry + url: https://open-telemetry.github.io/opentelemetry-helm-charts + chart: + name: otel + reference: open-telemetry/opentelemetry-collector + namespace: thecombine + wait: true + # Additional arguments to pass to helm install/upgrade + # values inside curly braces ({}) are interpreted as + # environment variables and their values will be substituted for + # the curly brace expression. + additional_args: + - --values + - "{SCRIPTS_DIR}/setup_files/collector_config.yaml" + - --set + - config.exporters.otlp.headers.x-honeycomb-team={HONEYCOMB_API_KEY} + cert-manager: repo: name: jetstack diff --git a/deploy/scripts/setup_files/collector_config.yaml b/deploy/scripts/setup_files/collector_config.yaml new file mode 100644 index 0000000000..e9a6cc2562 --- /dev/null +++ b/deploy/scripts/setup_files/collector_config.yaml @@ -0,0 +1,61 @@ +mode: "deployment" +namespaceOverride: "thecombine" +image: + repository: "otel/opentelemetry-collector-k8s" +config: + receivers: + jaeger: null + prometheus: null + zipkin: null + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + processors: + batch: {} + exporters: + otlp: + endpoint: "https://api.honeycomb.io:443" + service: + telemetry: + logs: + level: "INFO" + metrics: null + pipelines: + traces: + receivers: + - otlp + processors: + - batch + exporters: + - otlp + metrics: null + logs: null +ports: + otlp: + enabled: false + otlp-http: + enabled: true + containerPort: 4318 + servicePort: 4318 + hostPort: 4318 + protocol: TCP + jaeger-compact: + enabled: false + jaeger-thrift: + enabled: false + jaeger-grpc: + enabled: false + zipkin: + enabled: false + metrics: + enabled: false +useGOMEMLIMIT: true +resources: + requests: + cpu: 25m + memory: 256Mi + limits: + memory: 512Mi diff --git a/docs/user_guide/assets/licenses/backend_licenses.txt b/docs/user_guide/assets/licenses/backend_licenses.txt index 3219857553..635b47a558 100644 --- a/docs/user_guide/assets/licenses/backend_licenses.txt +++ b/docs/user_guide/assets/licenses/backend_licenses.txt @@ -27,6 +27,34 @@ Authors: MichaCo License: Apache-2.0 LicenseUrl: https://licenses.nuget.org/Apache-2.0 ############################################################### +PackageId: Google.Protobuf +PackageVersion: 3.22.5 +PackageProjectUrl: https://github.com/protocolbuffers/protobuf +Authors: Google Inc. +License: BSD-3-Clause +LicenseUrl: https://licenses.nuget.org/BSD-3-Clause +############################################################### +PackageId: Grpc.Core.Api +PackageVersion: 2.52.0 +PackageProjectUrl: https://github.com/grpc/grpc-dotnet +Authors: The gRPC Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### +PackageId: Grpc.Net.Client +PackageVersion: 2.52.0 +PackageProjectUrl: https://github.com/grpc/grpc-dotnet +Authors: The gRPC Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### +PackageId: Grpc.Net.Common +PackageVersion: 2.52.0 +PackageProjectUrl: https://github.com/grpc/grpc-dotnet +Authors: The gRPC Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### PackageId: icu.net PackageVersion: 2.10.1-beta.5 PackageProjectUrl: https://github.com/sillsdev/icu-dotnet @@ -75,6 +103,55 @@ Authors: Microsoft License: MIT LicenseUrl: https://licenses.nuget.org/MIT ############################################################### +PackageId: Microsoft.Extensions.Caching.Abstractions +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.Caching.Memory +PackageVersion: 8.0.1 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.Configuration +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.Configuration.Abstractions +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.Configuration.Binder +PackageVersion: 8.0.1 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.DependencyInjection +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.DependencyInjection.Abstractions +PackageVersion: 8.0.2 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### PackageId: Microsoft.Extensions.DependencyModel PackageVersion: 2.0.4 PackageProjectUrl: https://dot.net/ @@ -82,12 +159,68 @@ Authors: Microsoft.Extensions.DependencyModel License: https://github.com/dotnet/core-setup/blob/master/LICENSE.TXT LicenseUrl: https://github.com/dotnet/core-setup/blob/master/LICENSE.TXT ############################################################### +PackageId: Microsoft.Extensions.Diagnostics.Abstractions +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.FileProviders.Abstractions +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.Hosting.Abstractions +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.Logging +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### PackageId: Microsoft.Extensions.Logging.Abstractions -PackageVersion: 2.0.0 -PackageProjectUrl: https://asp.net/ +PackageVersion: 8.0.2 +PackageProjectUrl: https://dot.net/ Authors: Microsoft -License: Apache-2.0 -LicenseUrl: https://raw.githubusercontent.com/aspnet/Home/2.0.0/LICENSE.txt +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.Logging.Configuration +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.Options +PackageVersion: 8.0.2 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.Options.ConfigurationExtensions +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: Microsoft.Extensions.Primitives +PackageVersion: 8.0.0 +PackageProjectUrl: https://dot.net/ +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT ############################################################### PackageId: Microsoft.IdentityModel.Abstractions PackageVersion: 7.5.1 @@ -214,6 +347,62 @@ Authors: James Newton-King License: MIT LicenseUrl: https://licenses.nuget.org/MIT ############################################################### +PackageId: OpenTelemetry +PackageVersion: 1.8.1 +PackageProjectUrl: https://opentelemetry.io/ +Authors: OpenTelemetry Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### +PackageId: OpenTelemetry.Api +PackageVersion: 1.8.1 +PackageProjectUrl: https://opentelemetry.io/ +Authors: OpenTelemetry Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### +PackageId: OpenTelemetry.Api.ProviderBuilderExtensions +PackageVersion: 1.8.1 +PackageProjectUrl: https://opentelemetry.io/ +Authors: OpenTelemetry Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### +PackageId: OpenTelemetry.Exporter.Console +PackageVersion: 1.8.1 +PackageProjectUrl: https://opentelemetry.io/ +Authors: OpenTelemetry Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### +PackageId: OpenTelemetry.Exporter.OpenTelemetryProtocol +PackageVersion: 1.8.1 +PackageProjectUrl: https://opentelemetry.io/ +Authors: OpenTelemetry Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### +PackageId: OpenTelemetry.Extensions.Hosting +PackageVersion: 1.8.1 +PackageProjectUrl: https://opentelemetry.io/ +Authors: OpenTelemetry Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### +PackageId: OpenTelemetry.Instrumentation.AspNetCore +PackageVersion: 1.8.1 +PackageProjectUrl: https://opentelemetry.io/ +Authors: OpenTelemetry Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### +PackageId: OpenTelemetry.Instrumentation.Http +PackageVersion: 1.8.1 +PackageProjectUrl: https://opentelemetry.io/ +Authors: OpenTelemetry Authors +License: Apache-2.0 +LicenseUrl: https://licenses.nuget.org/Apache-2.0 +############################################################### PackageId: RelaxNG PackageVersion: 3.2.3 PackageProjectUrl: https://github.com/mono/mono/tree/master/mcs/class/Commons.Xml.Relaxng @@ -454,11 +643,11 @@ License: http://go.microsoft.com/fwlink/?LinkId=329770 LicenseUrl: http://go.microsoft.com/fwlink/?LinkId=329770 ############################################################### PackageId: System.Diagnostics.DiagnosticSource -PackageVersion: 4.3.0 +PackageVersion: 8.0.0 PackageProjectUrl: https://dot.net/ Authors: Microsoft -License: http://go.microsoft.com/fwlink/?LinkId=329770 -LicenseUrl: http://go.microsoft.com/fwlink/?LinkId=329770 +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT ############################################################### PackageId: System.Diagnostics.Tracing PackageVersion: 4.3.0 @@ -782,6 +971,20 @@ Authors: Microsoft License: http://go.microsoft.com/fwlink/?LinkId=329770 LicenseUrl: http://go.microsoft.com/fwlink/?LinkId=329770 ############################################################### +PackageId: System.Text.Encodings.Web +PackageVersion: 4.7.2 +PackageProjectUrl: https://github.com/dotnet/corefx +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### +PackageId: System.Text.Json +PackageVersion: 4.7.2 +PackageProjectUrl: https://github.com/dotnet/corefx +Authors: Microsoft +License: MIT +LicenseUrl: https://licenses.nuget.org/MIT +############################################################### PackageId: System.Threading PackageVersion: 4.3.0 PackageProjectUrl: https://dot.net/ diff --git a/src/backend/index.ts b/src/backend/index.ts index 250807bb5a..33bf1c40bc 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,4 +1,4 @@ -import axios, { AxiosError } from "axios"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; import { StatusCodes } from "http-status-codes"; import { Base64 } from "js-base64"; import { enqueueSnackbar } from "notistack"; @@ -26,6 +26,7 @@ import { Word, } from "api/models"; import * as LocalStorage from "backend/localStorage"; +import { getSessionId } from "backend/sessionStorage"; import authHeader from "components/Login/AuthHeaders"; import router from "router/browserRouter"; import { Goal, GoalStep } from "types/goals"; @@ -52,6 +53,10 @@ const whiteListedErrorUrls = [ // Create an axios instance to allow for attaching interceptors to it. const axiosInstance = axios.create({ baseURL: apiBaseURL }); +axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => { + config.headers.sessionId = getSessionId(); + return config; +}); axiosInstance.interceptors.response.use(undefined, (err: AxiosError) => { // Any status codes that falls outside the range of 2xx cause this function to // trigger. diff --git a/src/backend/sessionStorage.ts b/src/backend/sessionStorage.ts new file mode 100644 index 0000000000..de94ff2b58 --- /dev/null +++ b/src/backend/sessionStorage.ts @@ -0,0 +1,15 @@ +import { v4 } from "uuid"; + +export enum SessionStorageKey { + SessionId = "sessionId", +} + +/** Gets the current session id, generating and setting a new one if not one already. */ +export function getSessionId(): string { + let id = sessionStorage.getItem(SessionStorageKey.SessionId); + if (!id) { + id = v4(); + sessionStorage.setItem(SessionStorageKey.SessionId, id); + } + return id; +}