From ceaf7117d2063b2f5d64431058756bfc65ba026a Mon Sep 17 00:00:00 2001
From: Andra <78518344+andracc@users.noreply.github.com>
Date: Wed, 23 Oct 2024 15:28:42 -0400
Subject: [PATCH] Introduce OpenTelemetry (OTEL) instrumentation (#3163)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolves #3005
This PR instruments The Combine using OpenTelemetry for observability via the following changes:
* Adds OpenTelemetry instrumentation for tracing.
* Creates a LocationProvider that extracts the IP address from the http context and returns the IP’s location. This location information is added as tags to every trace.
* Adds the session ID as a tag to every trace to group events occurring during the same session.
* Starts new activities with custom trace tags in word-related functions to demonstrate more granular tracing.
* Installs [OpenTelemetry Collector Helm Chart](https://github.com/open-telemetry/opentelemetry-helm-charts/tree/main/charts/opentelemetry-collector) in Kubernetes cluster and adds custom configuration to send traces to Honeycomb and prepare foundation for additional data handling.
* Sets the OTEL_SERVICE_NAME during Kubernetes setup to select Honeycomb dataset according to the host on which The Combine is installed.
Additional changes in this PR:
* Fixes Kubernetes setup to properly interpret branch names containing '/' characters.
This change is [](https://reviewable.io/reviews/sillsdev/TheCombine/3163)
---------
Co-authored-by: Jim Grady
Co-authored-by: Danny Rorabaugh
---
Backend.Tests/Backend.Tests.csproj | 1 +
Backend.Tests/Mocks/LocationProviderMock.cs | 20 ++
Backend.Tests/Otel/LocationProviderTests.cs | 116 ++++++++++
Backend.Tests/Otel/OtelKernelTests.cs | 99 ++++++++
Backend.Tests/Otel/OtelServiceTests.cs | 39 ++++
Backend/BackendFramework.csproj | 10 +-
Backend/Controllers/WordController.cs | 27 +++
Backend/Interfaces/ILocationProvider.cs | 10 +
Backend/Otel/LocationApi.cs | 8 +
Backend/Otel/LocationProvider.cs | 61 +++++
Backend/Otel/OtelKernel.cs | 122 ++++++++++
Backend/Otel/OtelService.cs | 19 ++
Backend/Repositories/WordRepository.cs | 31 +++
Backend/Services/WordService.cs | 19 ++
Backend/Startup.cs | 8 +
.../charts/backend/templates/_helpers.tpl | 13 ++
.../backend/templates/deployment-backend.yaml | 6 +
deploy/scripts/app_release.py | 2 +-
deploy/scripts/setup_cluster.py | 8 +-
.../scripts/setup_files/cluster_config.yaml | 21 ++
.../scripts/setup_files/collector_config.yaml | 61 +++++
.../assets/licenses/backend_licenses.txt | 217 +++++++++++++++++-
src/backend/index.ts | 7 +-
src/backend/sessionStorage.ts | 15 ++
24 files changed, 928 insertions(+), 12 deletions(-)
create mode 100644 Backend.Tests/Mocks/LocationProviderMock.cs
create mode 100644 Backend.Tests/Otel/LocationProviderTests.cs
create mode 100644 Backend.Tests/Otel/OtelKernelTests.cs
create mode 100644 Backend.Tests/Otel/OtelServiceTests.cs
create mode 100644 Backend/Interfaces/ILocationProvider.cs
create mode 100644 Backend/Otel/LocationApi.cs
create mode 100644 Backend/Otel/LocationProvider.cs
create mode 100644 Backend/Otel/OtelKernel.cs
create mode 100644 Backend/Otel/OtelService.cs
create mode 100644 deploy/scripts/setup_files/collector_config.yaml
create mode 100644 src/backend/sessionStorage.ts
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;
+}