Added Serilog logging, added Elmah error logging, added health checks

This commit is contained in:
2025-06-09 17:09:55 +02:00
parent 60bfcff539
commit ce168cf9e4
10 changed files with 246 additions and 36 deletions

3
.gitignore vendored
View File

@@ -12,4 +12,5 @@ src/Models/bin
src/Models/obj src/Models/obj
src/Indexer/bin src/Indexer/bin
src/Indexer/obj src/Indexer/obj
src/Indexer/Scripts/__pycache__ src/Indexer/Scripts/__pycache__
src/Indexer/logs

View File

@@ -1,5 +1,10 @@
# Overview # Overview
The indexer by default
- runs on port 5210
- Uses Swagger UI in development mode (endpoint: `/swagger/index.html`)
- Ignores API keys when in development mode
- Uses Elmah error logging (endpoint: `/elmah`, local files: `~/logs`)
- Uses serilog logging (local files: `~/logs`)
## Installing the dependencies ## Installing the dependencies
## Ubuntu 24.04 ## Ubuntu 24.04
1. Install the .NET SDK: `sudo apt update && sudo apt install dotnet-sdk-8.0 -y` 1. Install the .NET SDK: `sudo apt update && sudo apt install dotnet-sdk-8.0 -y`

View File

@@ -2,7 +2,7 @@
The server by default The server by default
- runs on port 5146 - runs on port 5146
- Uses Swagger UI in development mode (`/swagger/index.html`) - Uses Swagger UI in development mode (`/swagger/index.html`)
- Ignores API keys when not in development mode - Ignores API keys when in development mode
# Installing the dependencies # Installing the dependencies
## Ubuntu 24.04 ## Ubuntu 24.04

View File

@@ -7,7 +7,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ElmahCore" Version="2.1.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.14" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.14" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Python" Version="3.13.3" /> <PackageReference Include="Python" Version="3.13.3" />
<PackageReference Include="Pythonnet" Version="3.0.5" /> <PackageReference Include="Pythonnet" Version="3.0.5" />

View File

@@ -0,0 +1,44 @@
using System.Collections.ObjectModel;
using Indexer.Models;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Indexer;
public class WorkerHealthCheck : IHealthCheck
{
private readonly WorkerCollection _workerCollection;
public WorkerHealthCheck(WorkerCollection workerCollection)
{
_workerCollection = workerCollection;
}
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
bool hasDegraded = false;
bool hasUnhealthy = false;
Dictionary<string, HealthStatus> degradedWorkerList = [];
foreach (Worker worker in _workerCollection.Workers)
{
HealthCheckResult workerHealth = worker.HealthCheck();
hasDegraded |= workerHealth.Status == HealthStatus.Degraded;
hasUnhealthy |= workerHealth.Status == HealthStatus.Unhealthy;
if (workerHealth.Status != HealthStatus.Healthy)
{
degradedWorkerList[worker.Name] = workerHealth.Status;
}
}
string degradedWorkerListString = "{" + string.Join(",", [.. degradedWorkerList.Select(kv => '"' + kv.Key + "\": " + kv.Value)]) + "}";
if (hasUnhealthy)
{
return Task.FromResult(
HealthCheckResult.Unhealthy(degradedWorkerListString));
}
else if (hasDegraded)
{
return Task.FromResult(
HealthCheckResult.Degraded(degradedWorkerListString));
}
return Task.FromResult(
HealthCheckResult.Healthy());
}
}

View File

@@ -3,6 +3,8 @@ namespace Indexer.Models;
public interface IScriptable public interface IScriptable
{ {
ScriptToolSet ToolSet { get; set; } ScriptToolSet ToolSet { get; set; }
ScriptUpdateInfo UpdateInfo { get; set; }
ILogger Logger { get; set; }
void Init(); void Init();
void Update(ICallbackInfos callbackInfos); void Update(ICallbackInfos callbackInfos);
bool IsScript(string filePath); bool IsScript(string filePath);

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
using System.Timers; using System.Timers;
using Python.Runtime; using Python.Runtime;
@@ -11,8 +10,11 @@ public class PythonScriptable : IScriptable
public PyModule scope; public PyModule scope;
public dynamic sys; public dynamic sys;
public string source; public string source;
public PythonScriptable(ScriptToolSet toolSet) public ScriptUpdateInfo UpdateInfo { get; set; }
public ILogger Logger { get; set; }
public PythonScriptable(ScriptToolSet toolSet, ILogger logger)
{ {
Logger = logger;
Runtime.PythonDLL = @"libpython3.12.so"; Runtime.PythonDLL = @"libpython3.12.so";
if (!PythonEngine.IsInitialized) if (!PythonEngine.IsInitialized)
{ {
@@ -37,24 +39,60 @@ public class PythonScriptable : IScriptable
public void Init() public void Init()
{ {
using (Py.GIL()) int retryCounter = 0;
retry:
try
{ {
pyToolSet = ToolSet.ToPython(); using (Py.GIL())
scope.Set("toolset", pyToolSet); {
scope.Exec(source); pyToolSet = ToolSet.ToPython();
scope.Exec("init(toolset)"); scope.Set("toolset", pyToolSet);
scope.Exec(source);
scope.Exec("init(toolset)");
}
} }
catch (Exception ex)
{
UpdateInfo = new() { DateTime = DateTime.Now, Successful = false, Exception = ex };
if (retryCounter < 3)
{
Logger.LogWarning("Unable to init the scriptable - retrying", [ToolSet.filePath, ex]);
retryCounter++;
goto retry;
}
Logger.LogError("Unable to init the scriptable", [ToolSet.filePath, ex]);
throw;
}
UpdateInfo = new() { DateTime = DateTime.Now, Successful = true };
} }
public void Update(ICallbackInfos callbackInfos) public void Update(ICallbackInfos callbackInfos)
{ {
using (Py.GIL()) int retryCounter = 0;
retry:
try
{ {
pyToolSet = ToolSet.ToPython(); using (Py.GIL())
pyToolSet.SetAttr("callbackInfos", callbackInfos.ToPython()); {
scope.Set("toolset", pyToolSet); pyToolSet = ToolSet.ToPython();
scope.Exec("update(toolset)"); pyToolSet.SetAttr("callbackInfos", callbackInfos.ToPython());
scope.Set("toolset", pyToolSet);
scope.Exec("update(toolset)");
}
} }
catch (Exception ex)
{
UpdateInfo = new() { DateTime = DateTime.Now, Successful = false, Exception = ex };
if (retryCounter < 3)
{
Logger.LogWarning("Execution of script failed to an exception - retrying", [ToolSet.filePath, ex]);
retryCounter++;
goto retry;
}
Logger.LogError("Execution of script failed to an exception", [ToolSet.filePath, ex]);
throw;
}
UpdateInfo = new() { DateTime = DateTime.Now, Successful = true };
} }
public bool IsScript(string fileName) public bool IsScript(string fileName)
@@ -87,5 +125,12 @@ public class IntervalCallbackInfos : ICallbackInfos
{ {
public object? sender; public object? sender;
public required ElapsedEventArgs e; public required ElapsedEventArgs e;
}
public struct ScriptUpdateInfo
{
public DateTime DateTime { get; set; }
public bool Successful { get; set; }
public Exception? Exception { get; set; }
} }

View File

@@ -1,3 +1,5 @@
using System.Diagnostics;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Indexer.Models; namespace Indexer.Models;
public class WorkerCollection public class WorkerCollection
@@ -13,13 +15,41 @@ public class WorkerCollection
public class Worker public class Worker
{ {
public string Name { get; set; }
public WorkerConfig Config { get; set; } public WorkerConfig Config { get; set; }
public IScriptable Scriptable { get; set; } public IScriptable Scriptable { get; set; }
public List<ICall> Calls { get; set; }
public Worker(WorkerConfig workerConfig, IScriptable scriptable) public Worker(string Name, WorkerConfig workerConfig, IScriptable scriptable)
{ {
this.Name = Name;
this.Config = workerConfig; this.Config = workerConfig;
this.Scriptable = scriptable; this.Scriptable = scriptable;
Calls = [];
}
public HealthCheckResult HealthCheck()
{
bool hasDegraded = false;
bool hasUnhealthy = false;
foreach (ICall call in Calls)
{
HealthCheckResult callHealth = call.HealthCheck();
if (callHealth.Status != HealthStatus.Healthy)
{
hasDegraded |= callHealth.Status == HealthStatus.Degraded;
hasUnhealthy |= callHealth.Status == HealthStatus.Unhealthy;
}
}
if (hasUnhealthy)
{
return HealthCheckResult.Unhealthy(); // TODO: Retrieve and forward the error message for each call
}
else if (hasDegraded)
{
return HealthCheckResult.Degraded();
}
return HealthCheckResult.Healthy();
} }
} }
@@ -33,13 +63,64 @@ public class WorkerConfig
public required string Name { get; set; } public required string Name { get; set; }
public required List<string> Searchdomains { get; set; } public required List<string> Searchdomains { get; set; }
public required string Script { get; set; } public required string Script { get; set; }
public required List<Call> Calls { get; set; } public required List<CallConfig> Calls { get; set; }
} }
public class Call public class CallConfig
{ {
public required string Type { get; set; } public required string Type { get; set; }
public long? Interval { get; set; } // For Type: Interval public long? Interval { get; set; } // For Type: Interval
public string? Path { get; set; } // For Type: FileSystemWatcher public string? Path { get; set; } // For Type: FileSystemWatcher
} }
public interface ICall
{
public HealthCheckResult HealthCheck();
}
public class IntervalCall : ICall
{
public System.Timers.Timer Timer;
public IScriptable Scriptable;
public IntervalCall(System.Timers.Timer timer, IScriptable scriptable)
{
Timer = timer;
Scriptable = scriptable;
}
public HealthCheckResult HealthCheck()
{
if (!Scriptable.UpdateInfo.Successful)
{
Debug.WriteLine(Scriptable.UpdateInfo.Exception);
return HealthCheckResult.Unhealthy();
}
double timerInterval = Timer.Interval; // In ms
DateTime lastRunDateTime = Scriptable.UpdateInfo.DateTime;
DateTime now = DateTime.Now;
double millisecondsSinceLastExecution = now.Subtract(lastRunDateTime).TotalMilliseconds;
if (millisecondsSinceLastExecution >= 2 * timerInterval)
{
return HealthCheckResult.Unhealthy();
}
return HealthCheckResult.Healthy();
}
}
public class ScheduleCall : ICall
{
public HealthCheckResult HealthCheck()
{
return HealthCheckResult.Unhealthy(); // Not implemented yet
}
}
public class FileUpdateCall : ICall
{
public HealthCheckResult HealthCheck()
{
return HealthCheckResult.Unhealthy(); // Not implemented yet
}
}

View File

@@ -1,6 +1,11 @@
using Indexer;
using Indexer.Models; using Indexer.Models;
using Indexer.Services; using Indexer.Services;
using ElmahCore;
using ElmahCore.Mvc;
using Server; using Server;
using ElmahCore.Mvc.Logger;
using Serilog;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -10,16 +15,32 @@ builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
Log.Logger = new LoggerConfiguration()
.WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day) // Output files with daily rolling
.CreateLogger();
builder.Logging.AddSerilog();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<Client.Client>(); builder.Services.AddSingleton<Client.Client>();
builder.Services.AddSingleton<WorkerCollection>(); builder.Services.AddSingleton<WorkerCollection>();
builder.Services.AddHostedService<IndexerService>(); builder.Services.AddHostedService<IndexerService>();
builder.Services.AddHealthChecks()
.AddCheck<WorkerHealthCheck>("WorkerHealthCheck");
builder.Services.AddElmah<XmlFileErrorLog>(Options =>
{
Options.LogPath = "~/logs";
});
var app = builder.Build(); var app = builder.Build();
app.UseElmah();
app.MapHealthChecks("/healthz");
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseElmahExceptionPage();
} }
else else
{ {

View File

@@ -1,12 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Indexer.Exceptions; using Indexer.Exceptions;
using Indexer.Models; using Indexer.Models;
using System.Timers; using ElmahCore;
using Microsoft.AspNetCore.Http.HttpResults;
using Python.Runtime;
namespace Indexer.Services; namespace Indexer.Services;
@@ -15,16 +9,18 @@ public class IndexerService : IHostedService
private readonly WorkerCollection workerCollection; private readonly WorkerCollection workerCollection;
private readonly IConfiguration _config; private readonly IConfiguration _config;
private readonly Client.Client client; private readonly Client.Client client;
public ILogger<IndexerService> _logger;
public IndexerService(WorkerCollection workerCollection, IConfiguration configuration, Client.Client client) public IndexerService(WorkerCollection workerCollection, IConfiguration configuration, Client.Client client, ILogger<IndexerService> logger, IHttpContextAccessor httpContextAccessor)
{ {
this._config = configuration; this._config = configuration;
this.client = client; this.client = client;
this.workerCollection = workerCollection; this.workerCollection = workerCollection;
_logger = logger;
// Load and configure all workers // Load and configure all workers
var sectionMain = _config.GetSection("EmbeddingsearchIndexer"); var sectionMain = _config.GetSection("EmbeddingsearchIndexer");
WorkerCollectionConfig? sectionWorker = (WorkerCollectionConfig?) sectionMain.Get(typeof(WorkerCollectionConfig)); //GetValue<WorkerCollectionConfig>("Worker"); WorkerCollectionConfig? sectionWorker = (WorkerCollectionConfig?)sectionMain.Get(typeof(WorkerCollectionConfig)); //GetValue<WorkerCollectionConfig>("Worker");
if (sectionWorker is not null) if (sectionWorker is not null)
{ {
foreach (WorkerConfig workerConfig in sectionWorker.Worker) foreach (WorkerConfig workerConfig in sectionWorker.Worker)
@@ -34,26 +30,38 @@ public class IndexerService : IHostedService
client.searchdomain = workerConfig.Searchdomains.First(); client.searchdomain = workerConfig.Searchdomains.First();
} }
ScriptToolSet toolSet = new(workerConfig.Script, client); ScriptToolSet toolSet = new(workerConfig.Script, client);
Worker worker = new(workerConfig, GetScriptable(toolSet)); Worker worker = new(workerConfig.Name, workerConfig, GetScriptable(toolSet));
workerCollection.Workers.Add(worker); workerCollection.Workers.Add(worker);
foreach (Call call in workerConfig.Calls) foreach (CallConfig callConfig in workerConfig.Calls)
{ {
switch (call.Type) switch (callConfig.Type)
{ {
case "interval": case "interval":
if (call.Interval is null) if (callConfig.Interval is null)
{ {
throw new IndexerConfigurationException($"Interval not set for a Call in Worker \"{workerConfig.Name}\""); throw new IndexerConfigurationException($"Interval not set for a Call in Worker \"{workerConfig.Name}\"");
} }
var timer = new System.Timers.Timer((double)call.Interval); var timer = new System.Timers.Timer((double)callConfig.Interval);
timer.Elapsed += (sender, e) => worker.Scriptable.Update(new IntervalCallbackInfos() { sender = sender, e = e }); timer.Elapsed += (sender, e) =>
{
try
{
worker.Scriptable.Update(new IntervalCallbackInfos() { sender = sender, e = e });
}
catch (Exception ex)
{
httpContextAccessor.HttpContext.RaiseError(ex);
}
};
timer.AutoReset = true; timer.AutoReset = true;
timer.Enabled = true; timer.Enabled = true;
IntervalCall call = new(timer, worker.Scriptable);
worker.Calls.Add(call);
break; break;
case "schedule": // TODO implement scheduled tasks using Quartz case "schedule": // TODO implement scheduled tasks using Quartz
throw new NotImplementedException("schedule not implemented yet"); throw new NotImplementedException("schedule not implemented yet");
case "fileupdate": case "fileupdate":
if (call.Path is null) if (callConfig.Path is null)
{ {
throw new IndexerConfigurationException($"Path not set for a Call in Worker \"{workerConfig.Name}\""); throw new IndexerConfigurationException($"Path not set for a Call in Worker \"{workerConfig.Name}\"");
} }
@@ -76,7 +84,7 @@ public class IndexerService : IHostedService
string fileName = toolSet.filePath; string fileName = toolSet.filePath;
foreach (Type type in workerCollection.types) foreach (Type type in workerCollection.types)
{ {
IScriptable? instance = (IScriptable?)Activator.CreateInstance(type, toolSet); IScriptable? instance = (IScriptable?)Activator.CreateInstance(type, [toolSet, _logger]);
if (instance is not null && instance.IsScript(fileName)) if (instance is not null && instance.IsScript(fileName))
{ {
return instance; return instance;