initial implementation of umami tracking

This commit is contained in:
2026-04-14 13:57:18 +02:00
parent 43fc5125de
commit 443e384af9
4 changed files with 188 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
using System.Net.Http;
using Microsoft.AspNetCore.Mvc;
using Finch.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
namespace Finch.Metadata;
[ApiController]
[Route("/api/hello")]
public class AnalyticsController : FinchController
{
protected IOptionsMonitor<AnalyticsOptions> Options { get; }
protected ILogger<AnalyticsController> Logger { get; }
protected IMemoryCache Cache { get; }
protected HttpClient Http { get; }
public AnalyticsController(IOptionsMonitor<AnalyticsOptions> options, ILogger<AnalyticsController> logger, IMemoryCache cache, HttpClient http)
{
Options = options;
Logger = logger;
Cache = cache;
Http = http;
// clear script cache when options change
options.OnChange(_ =>
{
cache.Remove(ScriptCacheKey);
});
}
private const string ScriptCacheKey = "umami_script_cache";
[HttpGet("friend.js")]
public async Task<IActionResult> GetTrackerJsFile()
{
if (!Options.CurrentValue.ProxyUrl.HasValue())
{
Logger.LogWarning("No proxy defined for umami analytics. Cancelling script load");
return NotFound();
}
// try to deliver the file from cache
if (Cache.TryGetValue(ScriptCacheKey, out string cachedString) && cachedString is not null)
{
return Content(cachedString, "text/javascript");
}
// load and cache the JS file
try
{
string uri = Options.CurrentValue.ProxyUrl + Options.CurrentValue.ProxyScriptEndpoint;
string response = await Http.GetStringAsync(uri, HttpContext.RequestAborted);
// do not push tracking-id via data-attribute but inject it into the file directly
const string replacementText = "(\"website-id\")";
int websiteIdIndex = response.IndexOf(replacementText, StringComparison.Ordinal) - 1;
response = response
.Remove(websiteIdIndex, replacementText.Length + 1)
.Insert(websiteIdIndex, $"\"{Options.CurrentValue.TrackingId}\"");
Cache.Set(ScriptCacheKey, response, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
});
return Content(response, "text/javascript");
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load umami script");
}
return NotFound();
}
[HttpPost("ping")]
public async Task<IActionResult> Ping()
{
if (!Options.CurrentValue.ProxyUrl.HasValue())
{
Logger.LogWarning("No proxy defined for umami analytics. Cancelling ping");
return NotFound();
}
// create proxy request
string uri = Options.CurrentValue.ProxyUrl + Options.CurrentValue.ProxyTrackEndpoint;
using HttpRequestMessage proxyRequest = new(HttpMethod.Post, uri);
// forward real IP
proxyRequest.Headers.TryAddWithoutValidation("X-Forwarded-For", HttpContext.Connection.RemoteIpAddress?.ToString());
proxyRequest.Headers.TryAddWithoutValidation("X-Real-IP", HttpContext.Connection.RemoteIpAddress?.ToString());
// append additional headers from client
foreach (KeyValuePair<string, StringValues> header in Request.Headers)
{
if (!proxyRequest.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()))
{
proxyRequest.Content ??= new StreamContent(Request.Body);
proxyRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
}
}
proxyRequest.Content ??= new StreamContent(Request.Body);
// add requested content-type
if (!string.IsNullOrEmpty(Request.ContentType))
{
proxyRequest.Content.Headers.TryAddWithoutValidation("Content-Type", Request.ContentType);
}
// send proxy request to umami
try
{
using HttpResponseMessage proxyResponse = await Http.SendAsync(proxyRequest, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted);
string responseBody = await proxyResponse.Content.ReadAsStringAsync(HttpContext.RequestAborted);
return StatusCode((int)proxyResponse.StatusCode, responseBody);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to contact umami service");
}
return NotFound();
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Finch.Metadata;
public class AnalyticsOptions
{
public bool Enabled { get; set; } = true;
public string Endpoint { get; set; } = "/api/hello";
public string ProxyUrl { get; set; }
public string ProxyScriptEndpoint { get; set; } = "/hi.js";
public string ProxyTrackEndpoint { get; set; } = "/ping";
public string TrackingId { get; set; }
public bool Valid()
{
return Enabled && TrackingId.HasValue() && ProxyUrl.HasValue();
}
}
@@ -0,0 +1,35 @@
using Finch.Metadata;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Finch.TagHelpers;
[HtmlTargetElement("app-tracker", TagStructure = TagStructure.NormalOrSelfClosing)]
public class AnalyticsTrackerTagHelper(IOptionsMonitor<AnalyticsOptions> options, IHostEnvironment env) : TagHelper
{
[HtmlAttributeNotBound]
[ViewContext]
public ViewContext ViewContext { get; set; } = null!;
private readonly AnalyticsOptions _options = options.CurrentValue;
public override void Process(TagHelperContext context, TagHelperOutput output)
{
if (!_options.Valid() || !env.IsProduction())
{
output.SuppressOutput();
return;
}
output.TagName = "script";
output.TagMode = TagMode.StartTagAndEndTag;
output.Attributes.SetAttribute("defer", "true");
output.Attributes.SetAttribute("src", _options.Endpoint + "/friend.js");
// the website is injected into the script
//output.Attributes.SetAttribute("data-website-id", _options.TrackingId);
output.Attributes.SetAttribute("data-performance", "true");
}
}
+2
View File
@@ -8,5 +8,7 @@ public class FinchMetadataModule : FinchModule
public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{ {
services.AddScoped<IMetadataService, MetadataService>(); services.AddScoped<IMetadataService, MetadataService>();
services.AddHttpClient<AnalyticsController>();
services.AddOptions<AnalyticsOptions>().Bind(configuration.GetSection("Finch:Analytics"));
} }
} }