diff --git a/Fuchs.Tests/ProcessWebComServiceTests.cs b/Fuchs.Tests/ProcessWebComServiceTests.cs
new file mode 100644
index 0000000..ff14a0b
--- /dev/null
+++ b/Fuchs.Tests/ProcessWebComServiceTests.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Metrics;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Fuchs.Services;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace Fuchs.Tests;
+
+///
+/// Tests for the outbound communication service. Cover both intentionally
+/// succeeding (API 200) and intentionally failing (invalid email, disabled,
+/// API 500) paths, plus the attachment payload contract and metric emission.
+///
+/// Fuchs_intranet is passed as null: it is only touched by the audit-log write,
+/// which is wrapped in try/catch and therefore harmless in a unit test.
+///
+public class ProcessWebComServiceTests
+{
+ // ── Test doubles ────────────────────────────────────────────────────────
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ private readonly HttpStatusCode _status;
+ private readonly string _body;
+ public string? LastRequestBody { get; private set; }
+ public int CallCount { get; private set; }
+
+ public StubHandler(HttpStatusCode status, string body = "OK")
+ {
+ _status = status;
+ _body = body;
+ }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ CallCount++;
+ LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
+ return new HttpResponseMessage(_status) { Content = new StringContent(_body) };
+ }
+ }
+
+ private sealed class StubHttpClientFactory : IHttpClientFactory
+ {
+ private readonly HttpMessageHandler _handler;
+ public StubHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
+ public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
+ }
+
+ private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true)
+ {
+ var settings = Options.Create(new ProcessWebComSettings
+ {
+ Enabled = enabled,
+ BaseUrl = "https://mailer.test",
+ AccountId = "acct",
+ Token = "tok"
+ });
+ return new ProcessWebComService(
+ NullLogger.Instance,
+ intranet: null!,
+ settings,
+ new StubHttpClientFactory(handler));
+ }
+
+ // ── Success path ─────────────────────────────────────────────────────────
+ [Fact]
+ public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPostsOnce()
+ {
+ var handler = new StubHandler(HttpStatusCode.OK);
+ var svc = CreateService(handler);
+
+ bool result = await svc.SendEmailAsync("inv_1", "Subject", "hi
", "kunde@example.de", "Kunde");
+
+ Assert.True(result);
+ Assert.Equal(1, handler.CallCount);
+ }
+
+ // ── Failure paths ──────────────────────────────────────────────────────────
+ [Fact]
+ public async Task SendEmailAsync_ApiError_ReturnsFalse()
+ {
+ var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom");
+ var svc = CreateService(handler);
+
+ bool result = await svc.SendEmailAsync("inv_2", "Subject", "hi
", "kunde@example.de", "Kunde");
+
+ Assert.False(result);
+ Assert.Equal(1, handler.CallCount);
+ }
+
+ [Theory]
+ [InlineData("not-an-email")]
+ [InlineData("")]
+ [InlineData("missing@")]
+ public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingApi(string badEmail)
+ {
+ var handler = new StubHandler(HttpStatusCode.OK);
+ var svc = CreateService(handler);
+
+ bool result = await svc.SendEmailAsync("inv_3", "Subject", "hi
", badEmail, "Kunde");
+
+ Assert.False(result);
+ Assert.Equal(0, handler.CallCount); // never reached the API
+ }
+
+ [Fact]
+ public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingApi()
+ {
+ var handler = new StubHandler(HttpStatusCode.OK);
+ var svc = CreateService(handler, enabled: false);
+
+ bool result = await svc.SendEmailAsync("inv_4", "Subject", "hi
", "kunde@example.de", "Kunde");
+
+ Assert.False(result);
+ Assert.Equal(0, handler.CallCount);
+ }
+
+ // ── Attachment payload contract ────────────────────────────────────────────
+ [Fact]
+ public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()
+ {
+ var handler = new StubHandler(HttpStatusCode.OK);
+ var svc = CreateService(handler);
+ byte[] pdf = { 1, 2, 3, 4 };
+ var attachments = new Dictionary { ["Rechnung.pdf"] = pdf };
+
+ bool result = await svc.SendEmailAsync("inv_5", "Subject", "hi
", "kunde@example.de", "Kunde", attachments);
+
+ Assert.True(result);
+ Assert.NotNull(handler.LastRequestBody);
+ var json = JObject.Parse(handler.LastRequestBody!);
+ var att = (JArray)json["attachments"]!;
+ Assert.Single(att);
+ Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
+ Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString());
+ Assert.Equal(Convert.ToBase64String(pdf), att[0]!["contentBase64"]!.ToString());
+ }
+
+ // ── SMS ────────────────────────────────────────────────────────────────────
+ [Fact]
+ public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrue()
+ {
+ var handler = new StubHandler(HttpStatusCode.OK);
+ var svc = CreateService(handler);
+
+ bool result = await svc.SendSmsAsync("01700000000", "Code 1234");
+
+ Assert.True(result);
+ Assert.Equal(1, handler.CallCount);
+ }
+
+ [Fact]
+ public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingApi()
+ {
+ var handler = new StubHandler(HttpStatusCode.OK);
+ var svc = CreateService(handler);
+
+ bool result = await svc.SendSmsAsync("", "Code 1234");
+
+ Assert.False(result);
+ Assert.Equal(0, handler.CallCount);
+ }
+
+ // ── Metric emission (performance indicator) ────────────────────────────────
+ [Fact]
+ public async Task SendEmailAsync_Success_IncrementsEmailsSentCounter()
+ {
+ long delta = 0;
+ using var listener = new MeterListener
+ {
+ InstrumentPublished = (inst, l) =>
+ {
+ if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.sent")
+ l.EnableMeasurementEvents(inst);
+ }
+ };
+ listener.SetMeasurementEventCallback((_, value, _, _) => Interlocked.Add(ref delta, value));
+ listener.Start();
+
+ var svc = CreateService(new StubHandler(HttpStatusCode.OK));
+ await svc.SendEmailAsync("inv_metric", "S", "x
", "kunde@example.de", "Kunde");
+
+ Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send.");
+ }
+
+ private const string FuchsMeterName = "Fuchs.Intranet";
+}
diff --git a/Fuchs/Controllers/IntranetController.Banking.cs b/Fuchs/Controllers/IntranetController.Banking.cs
index 36c4e4a..5ec3a79 100644
--- a/Fuchs/Controllers/IntranetController.Banking.cs
+++ b/Fuchs/Controllers/IntranetController.Banking.cs
@@ -15,12 +15,15 @@ public partial class IntranetController
{
private async Task Do_Process_Bankings(string fn, string id, string code)
{
+ _logger.LogDebug("Do_Process_Bankings action={Action} user={User}", id, UserAccountID);
switch (id.ToLower())
{
case "auth":
return await JSONAsync(new { manage = 1 });
case "up":
+ _logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}",
+ Request.Form.Files.Count, UserAccountID);
foreach (var fle in Request.Form.Files)
{
using var stream = fle.OpenReadStream();
@@ -50,6 +53,8 @@ public partial class IntranetController
dtwa.OnCommandAfterError += (_, exc) =>
_intranet.debug_log("IntranetController.bam.up - command-after exception",
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
+ _logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
+ tbl.Rows.Count, UserAccountID);
dtwa.DoSubmit();
}
return Ok();
diff --git a/Fuchs/Controllers/IntranetController.Reminder.cs b/Fuchs/Controllers/IntranetController.Reminder.cs
index 3d2a1d1..6dd22d3 100644
--- a/Fuchs/Controllers/IntranetController.Reminder.cs
+++ b/Fuchs/Controllers/IntranetController.Reminder.cs
@@ -17,6 +17,7 @@ public partial class IntranetController
{
private async Task Do_Process_Reminder(string fn, string id, string code)
{
+ _logger.LogDebug("Do_Process_Reminder action={Action} user={User}", id, UserAccountID);
switch (id.ToLower())
{
case "get":
@@ -117,6 +118,8 @@ public partial class IntranetController
frdic.no("InvoiceFile", null!) is byte[] invFile)
remdoc[frdic.nz("InvoiceFileName")] = invFile;
+ _logger.LogInformation("Reminder conf: emailing finalized reminder {RemId} to {Email} user={User}",
+ remId, email.Trim(), UserAccountID);
bool sent = await _comService.SendEmailAsync(
$"inv_{remId}",
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
diff --git a/Fuchs/Controllers/IntranetController.Reports.cs b/Fuchs/Controllers/IntranetController.Reports.cs
index aa88b06..d86999a 100644
--- a/Fuchs/Controllers/IntranetController.Reports.cs
+++ b/Fuchs/Controllers/IntranetController.Reports.cs
@@ -13,6 +13,7 @@ public partial class IntranetController
{
private async Task Do_Process_Reports(string fn, string id, string code)
{
+ _logger.LogDebug("Do_Process_Reports action={Action} code={Code} user={User}", id, code, UserAccountID);
switch (id.ToLower())
{
case "auth":
diff --git a/Fuchs/Controllers/IntranetController.Requests.cs b/Fuchs/Controllers/IntranetController.Requests.cs
index 49c2004..3d06df1 100644
--- a/Fuchs/Controllers/IntranetController.Requests.cs
+++ b/Fuchs/Controllers/IntranetController.Requests.cs
@@ -19,6 +19,7 @@ public partial class IntranetController
{
private async Task Do_Process_Requests(string fn, string id, string code)
{
+ _logger.LogDebug("Do_Process_Requests action={Action} user={User}", id, UserAccountID);
switch (id.ToLower())
{
case "auth":
@@ -277,6 +278,8 @@ public partial class IntranetController
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
string body = BuildInvoiceBody(bal, terms);
+ _logger.LogInformation("Request sconf: emailing finalized invoice {InvId} to {Email} user={User}",
+ invId, email.Trim(), UserAccountID);
bool sent = await _comService.SendEmailAsync(
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
body, email.Trim(), "", inv);
diff --git a/Fuchs/Fuchs.csproj b/Fuchs/Fuchs.csproj
index 8ac955d..e6f800e 100644
--- a/Fuchs/Fuchs.csproj
+++ b/Fuchs/Fuchs.csproj
@@ -33,6 +33,12 @@
+
+
+
+
+
+
diff --git a/Fuchs/Observability/FuchsTelemetry.cs b/Fuchs/Observability/FuchsTelemetry.cs
new file mode 100644
index 0000000..8b8027f
--- /dev/null
+++ b/Fuchs/Observability/FuchsTelemetry.cs
@@ -0,0 +1,56 @@
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace Fuchs.Observability;
+
+///
+/// Central definition of the application's OpenTelemetry instrumentation:
+/// a single for tracing and a single
+/// with the business / performance instruments.
+///
+/// These use the in-box System.Diagnostics APIs, which the OpenTelemetry
+/// SDK (wired up in Program.cs) collects and exports. Code can therefore
+/// emit spans and metrics without taking a direct dependency on the OTel SDK.
+///
+public static class FuchsTelemetry
+{
+ public const string ServiceName = "Fuchs.Intranet";
+ public static readonly string ServiceVersion =
+ typeof(FuchsTelemetry).Assembly.GetName().Version?.ToString() ?? "1.0.0";
+
+ /// Tracing source — register this name with the tracer provider.
+ public static readonly ActivitySource ActivitySource = new(ServiceName, ServiceVersion);
+
+ /// Metrics meter — register this name with the meter provider.
+ public static readonly Meter Meter = new(ServiceName, ServiceVersion);
+
+ // ── Business counters ────────────────────────────────────────────────────
+ public static readonly Counter InvoicesRendered =
+ Meter.CreateCounter("fuchs.invoices.rendered", "{invoice}", "Number of invoice PDFs rendered.");
+ public static readonly Counter RemindersRendered =
+ Meter.CreateCounter("fuchs.reminders.rendered", "{reminder}", "Number of reminder PDFs rendered.");
+ public static readonly Counter ReportsRendered =
+ Meter.CreateCounter("fuchs.reports.rendered", "{report}", "Number of reports rendered (by function).");
+ public static readonly Counter EmailsSent =
+ Meter.CreateCounter("fuchs.emails.sent", "{email}", "Number of emails accepted by the mailer API.");
+ public static readonly Counter EmailsFailed =
+ Meter.CreateCounter("fuchs.emails.failed", "{email}", "Number of emails that failed to send.");
+ public static readonly Counter SmsSent =
+ Meter.CreateCounter("fuchs.sms.sent", "{sms}", "Number of SMS messages sent.");
+ public static readonly Counter Mt940RowsParsed =
+ Meter.CreateCounter("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed.");
+ public static readonly Counter MfrCalls =
+ Meter.CreateCounter("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated.");
+
+ // ── Performance histograms (durations in milliseconds) ───────────────────
+ public static readonly Histogram PdfRenderDuration =
+ Meter.CreateHistogram("fuchs.pdf.render.duration", "ms", "PDF render duration.");
+ public static readonly Histogram ReportRenderDuration =
+ Meter.CreateHistogram("fuchs.report.render.duration", "ms", "Report render duration.");
+ public static readonly Histogram EmailSendDuration =
+ Meter.CreateHistogram("fuchs.email.send.duration", "ms", "Email send round-trip duration.");
+
+ /// Starts a span on the application's .
+ public static Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) =>
+ ActivitySource.StartActivity(name, kind);
+}
diff --git a/Fuchs/Program.cs b/Fuchs/Program.cs
index 4aa62ab..6a5b321 100644
--- a/Fuchs/Program.cs
+++ b/Fuchs/Program.cs
@@ -1,5 +1,6 @@
using Fuchs.intranet;
using Fuchs.Logging;
+using Fuchs.Observability;
using OCORE_web.Secrets;
using Fuchs.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
@@ -8,6 +9,9 @@ using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using OCORE.security;
using OCORE.web;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
namespace Fuchs;
@@ -84,6 +88,38 @@ public class Program
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+
+ // ── OpenTelemetry: tracing + metrics ─────────────────────────────────
+ // Instrumentation is always collected; OTLP export is enabled only when
+ // an endpoint is configured (Fuchs:Telemetry:OtlpEndpoint), so a missing
+ // collector never impacts the app. Disable entirely with Enabled=false.
+ bool telemetryEnabled = builder.Configuration.GetValue("Fuchs:Telemetry:Enabled", true);
+ string? otlpEndpoint = builder.Configuration["Fuchs:Telemetry:OtlpEndpoint"];
+ if (telemetryEnabled)
+ {
+ builder.Services.AddOpenTelemetry()
+ .ConfigureResource(r => r.AddService(
+ serviceName: FuchsTelemetry.ServiceName,
+ serviceVersion: FuchsTelemetry.ServiceVersion))
+ .WithTracing(t =>
+ {
+ t.AddSource(FuchsTelemetry.ServiceName)
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddSqlClientInstrumentation();
+ if (!string.IsNullOrWhiteSpace(otlpEndpoint))
+ t.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint));
+ })
+ .WithMetrics(m =>
+ {
+ m.AddMeter(FuchsTelemetry.ServiceName)
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ if (!string.IsNullOrWhiteSpace(otlpEndpoint))
+ m.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint));
+ });
+ }
}
private static void ConfigureApp(WebApplication app)
diff --git a/Fuchs/Services/BankingService.cs b/Fuchs/Services/BankingService.cs
index b2b12b3..715980a 100644
--- a/Fuchs/Services/BankingService.cs
+++ b/Fuchs/Services/BankingService.cs
@@ -1,4 +1,6 @@
using System.Data;
+using System.Diagnostics;
+using Fuchs.Observability;
using Microsoft.Extensions.Logging;
using programmersdigest.MT940Parser;
@@ -27,6 +29,8 @@ public class BankingService : IBankingService
public DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null)
{
+ using var act = FuchsTelemetry.StartActivity("banking.mt940.parse");
+ var sw = Stopwatch.StartNew();
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
void SetNfo(DataRow nr, string key, object? value)
@@ -94,6 +98,10 @@ public class BankingService : IBankingService
}
tbl.AcceptChanges();
+ sw.Stop();
+ FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count);
+ act?.SetTag("fuchs.banking.rows", tbl.Rows.Count);
+ _logger.LogInformation("MT940 parsed {Rows} transaction lines in {Ms} ms", tbl.Rows.Count, sw.ElapsedMilliseconds);
return tbl;
}
diff --git a/Fuchs/Services/FuchsPdfService.cs b/Fuchs/Services/FuchsPdfService.cs
index c21b96e..3e05209 100644
--- a/Fuchs/Services/FuchsPdfService.cs
+++ b/Fuchs/Services/FuchsPdfService.cs
@@ -1,4 +1,6 @@
-using Fuchs.intranet;
+using System.Diagnostics;
+using Fuchs.intranet;
+using Fuchs.Observability;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
@@ -6,7 +8,8 @@ namespace Fuchs.Services;
///
/// PDF service implementation. Delegates to static methods
-/// while providing a DI-friendly injectable wrapper.
+/// while providing a DI-friendly injectable wrapper, structured logging and
+/// render-duration metrics.
///
public class FuchsPdfService : IPdfService
{
@@ -16,30 +19,94 @@ public class FuchsPdfService : IPdfService
{
_logger = logger;
FuchsPdf.SetLicense();
+ _logger.LogDebug("FuchsPdfService initialised (PDF license applied).");
}
public Task WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft)
{
+ _logger.LogDebug("WriteLetterAsync draft={Draft}", draft);
return FuchsPdf.WriteLetter(textBlocks, draft, FuchsPdf.DeCulture);
}
public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsInvoiceData invoice, bool draft = false)
{
+ _logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft);
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft);
}
public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsReminderData reminder, bool draft = false)
{
+ _logger.LogDebug("ApplyReminder id={Id} draft={Draft}", reminder.Id, draft);
FuchsPdf.ApplyReminder(doc, textBlocks, reminder, draft);
}
- public byte[] DocToPdfBytes(Document doc) => FuchsPdf.DocToPdfBytes(doc);
+ public byte[] DocToPdfBytes(Document doc)
+ {
+ var sw = Stopwatch.StartNew();
+ using var act = FuchsTelemetry.StartActivity("pdf.render");
+ try
+ {
+ byte[] bytes = FuchsPdf.DocToPdfBytes(doc);
+ sw.Stop();
+ FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
+ new KeyValuePair("operation", "pdf"));
+ act?.SetTag("fuchs.pdf.bytes", bytes.Length);
+ _logger.LogDebug("DocToPdfBytes rendered {Bytes} bytes in {Ms} ms", bytes.Length, sw.ElapsedMilliseconds);
+ return bytes;
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ act?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ _logger.LogError(ex, "DocToPdfBytes failed after {Ms} ms", sw.ElapsedMilliseconds);
+ throw;
+ }
+ }
- public Task DocToImageCollectionAsync(Document doc) =>
- FuchsPdf.DocToImageCollection(doc);
+ public async Task DocToImageCollectionAsync(Document doc)
+ {
+ var sw = Stopwatch.StartNew();
+ using var act = FuchsTelemetry.StartActivity("pdf.render.images");
+ try
+ {
+ var col = await FuchsPdf.DocToImageCollection(doc);
+ sw.Stop();
+ FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
+ new KeyValuePair("operation", "images"));
+ act?.SetTag("fuchs.pdf.pages", col.TotalPages);
+ _logger.LogDebug("DocToImageCollectionAsync rendered {Pages} pages in {Ms} ms", col.TotalPages, sw.ElapsedMilliseconds);
+ return col;
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ act?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ _logger.LogError(ex, "DocToImageCollectionAsync failed after {Ms} ms", sw.ElapsedMilliseconds);
+ throw;
+ }
+ }
- public Task BytesToImageCollectionAsync(byte[] pdfBytes) =>
- FuchsPdf.BytesToImageCollection(pdfBytes);
+ public async Task BytesToImageCollectionAsync(byte[] pdfBytes)
+ {
+ var sw = Stopwatch.StartNew();
+ using var act = FuchsTelemetry.StartActivity("pdf.bytes.images");
+ try
+ {
+ var col = await FuchsPdf.BytesToImageCollection(pdfBytes);
+ sw.Stop();
+ FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
+ new KeyValuePair("operation", "bytes-images"));
+ _logger.LogDebug("BytesToImageCollectionAsync rendered {Pages} pages in {Ms} ms", col.TotalPages, sw.ElapsedMilliseconds);
+ return col;
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ act?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ _logger.LogError(ex, "BytesToImageCollectionAsync failed after {Ms} ms", sw.ElapsedMilliseconds);
+ throw;
+ }
+ }
}
diff --git a/Fuchs/Services/FuchsReportService.cs b/Fuchs/Services/FuchsReportService.cs
index e941f5e..42b4421 100644
--- a/Fuchs/Services/FuchsReportService.cs
+++ b/Fuchs/Services/FuchsReportService.cs
@@ -1,4 +1,6 @@
+using System.Diagnostics;
using Fuchs.intranet;
+using Fuchs.Observability;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
@@ -69,6 +71,12 @@ public class FuchsReportService : IReportService
}
bool ciForce = prms.TryGetValue("cache", out var ca) && ca.ToLower() == "0";
+ var sw = Stopwatch.StartNew();
+ using var act = FuchsTelemetry.StartActivity("report.process");
+ act?.SetTag("fuchs.report.fn", tgt);
+ act?.SetTag("fuchs.report.id", report);
+ _logger.LogInformation("Report request fnc={Fnc} report={Report} tgt={Tgt} cache={Cache} user={User}",
+ fnc, report, tgt, ciCache, userAccountId);
try
{
switch (tgt)
@@ -119,9 +127,18 @@ public class FuchsReportService : IReportService
}
catch (Exception ex)
{
+ act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Report processing failed fnc={Fnc} report={Report} tgt={Tgt}", fnc, report, tgt);
return new StatusCodeResult(500);
}
+ finally
+ {
+ sw.Stop();
+ FuchsTelemetry.ReportRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
+ new KeyValuePair("fn", tgt));
+ FuchsTelemetry.ReportsRendered.Add(1, new KeyValuePair("fn", tgt));
+ _logger.LogDebug("Report request completed tgt={Tgt} report={Report} in {Ms} ms", tgt, report, sw.ElapsedMilliseconds);
+ }
}
private static void ApplyReload(FuchsHtmlPage page, IDictionary prms, int ciRefresh)
diff --git a/Fuchs/Services/FuchsWidgetService.cs b/Fuchs/Services/FuchsWidgetService.cs
index 5adfe6d..51283d4 100644
--- a/Fuchs/Services/FuchsWidgetService.cs
+++ b/Fuchs/Services/FuchsWidgetService.cs
@@ -1,4 +1,5 @@
using Fuchs.intranet;
+using Fuchs.Observability;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -31,17 +32,24 @@ public class FuchsWidgetService : IWidgetService
public async Task GetWidgetAsync(string widgetId, string userAccountId,
DatabaseSecurity dbSec, HttpRequest request)
{
+ using var act = FuchsTelemetry.StartActivity("widget.get");
+ act?.SetTag("fuchs.widget.id", widgetId);
+ _logger.LogDebug("GetWidgetAsync widget={WidgetId} user={User}", widgetId, userAccountId);
try
{
- return widgetId.ToLower() switch
+ var result = widgetId.ToLower() switch
{
"my" => await HandleWidgetMy(userAccountId, dbSec),
"one" => await HandleWidgetOne(userAccountId, dbSec, request),
_ => await HandleWidgetGeneric(widgetId, userAccountId, dbSec)
};
+ _logger.LogDebug("GetWidgetAsync widget={WidgetId} result={Result} user={User}",
+ widgetId, result.GetType().Name, userAccountId);
+ return result;
}
catch (Exception ex)
{
+ act?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Widget error for {WidgetId}, user {UserAccountId}", widgetId, userAccountId);
return new StatusCodeResult(500);
}
diff --git a/Fuchs/Services/InvoiceService.cs b/Fuchs/Services/InvoiceService.cs
index 1107a95..2808515 100644
--- a/Fuchs/Services/InvoiceService.cs
+++ b/Fuchs/Services/InvoiceService.cs
@@ -1,5 +1,7 @@
using System.Data;
+using System.Diagnostics;
using Fuchs.intranet;
+using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
@@ -33,8 +35,9 @@ public class InvoiceService : IInvoiceService
public async Task LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
{
+ _logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
var inv = new FdsInvoiceData();
- if (string.IsNullOrEmpty(id)) return inv;
+ if (string.IsNullOrEmpty(id)) { _logger.LogWarning("LoadInvoiceAsync called with empty id (user={User})", userAccountId); return inv; }
var pl = new List
{
@@ -51,13 +54,15 @@ public class InvoiceService : IInvoiceService
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
+ _logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft}", inv.Id, inv.IsDraft);
return inv;
}
public async Task RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec)
{
- if (invoice.NewValues == null) return invoice;
+ _logger.LogInformation("RegisterInvoiceAsync change={Change} invId={InvId} user={User}", change, invId, userAccountId);
+ if (invoice.NewValues == null) { _logger.LogWarning("RegisterInvoiceAsync: no form values supplied (user={User})", userAccountId); return invoice; }
var pl = new List { SQL_VarChar("@authuser", userAccountId) };
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
@@ -88,11 +93,16 @@ public class InvoiceService : IInvoiceService
_logger.LogError("RegisterInvoiceAsync sql exception: {Ex}", invdset.Exception);
invoice.InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
+ _logger.LogInformation("RegisterInvoiceAsync registered id={Id} (change={Change})", invoice.Id, change);
return invoice;
}
public Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft)
{
+ using var act = FuchsTelemetry.StartActivity("invoice.render");
+ act?.SetTag("fuchs.invoice.id", invoice.Id);
+ act?.SetTag("fuchs.invoice.draft", draft);
+ _logger.LogDebug("GenerateInvoicePdf id={Id} draft={Draft}", invoice.Id, draft);
var reg = invoice.InvoiceRegistration;
var tb = new FuchsPdf.FdsTextBlocks
{
@@ -108,6 +118,7 @@ public class InvoiceService : IInvoiceService
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
_pdf.ApplyInvoice(doc, tb, invoice, draft);
+ FuchsTelemetry.InvoicesRendered.Add(1, new KeyValuePair("draft", draft));
return doc;
}
diff --git a/Fuchs/Services/MfrClientFactory.cs b/Fuchs/Services/MfrClientFactory.cs
index e402520..654d04e 100644
--- a/Fuchs/Services/MfrClientFactory.cs
+++ b/Fuchs/Services/MfrClientFactory.cs
@@ -1,22 +1,28 @@
-using Microsoft.Extensions.Logging;
+using Fuchs.Observability;
+using Microsoft.Extensions.Logging;
namespace Fuchs.Services;
///
/// Factory implementation for .
-/// Centralizes MFR client creation and supplies logger from DI.
+/// Centralizes MFR client creation, supplies the logger from DI, and counts
+/// client instantiations as a proxy for MFR ERP interactions.
///
public class MfrClientFactory : IMfrClientFactory
{
private readonly ILoggerFactory _loggerFactory;
+ private readonly ILogger _logger;
public MfrClientFactory(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
+ _logger = loggerFactory.CreateLogger();
}
public fds.FdsMfrClient Create()
{
+ FuchsTelemetry.MfrCalls.Add(1);
+ _logger.LogDebug("Creating new FdsMfrClient instance.");
return new fds.FdsMfrClient(_loggerFactory);
}
}
diff --git a/Fuchs/Services/ProcessWebComService.cs b/Fuchs/Services/ProcessWebComService.cs
index a7e64ef..6c5183c 100644
--- a/Fuchs/Services/ProcessWebComService.cs
+++ b/Fuchs/Services/ProcessWebComService.cs
@@ -1,6 +1,8 @@
-using System.Net.Http.Headers;
+using System.Diagnostics;
+using System.Net.Http.Headers;
using System.Text;
using Fuchs.intranet;
+using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -43,9 +45,13 @@ public class ProcessWebComService : IComService
public async Task SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary? attachments = null)
{
+ using var act = FuchsTelemetry.StartActivity("email.send");
+ act?.SetTag("fuchs.email.ref", reference);
if (!IsValidEmail(email))
{
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
+ FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair("reason", "invalid-email"));
+ act?.SetStatus(ActivityStatusCode.Error, "invalid-email");
return false;
}
@@ -57,12 +63,14 @@ public class ProcessWebComService : IComService
"[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}",
reference, subject, email);
await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled – email not sent"]);
+ FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair("reason", "disabled"));
return false;
}
bool success = false;
string messageId = "";
var errors = new List();
+ var sw = Stopwatch.StartNew();
try
{
@@ -105,9 +113,18 @@ public class ProcessWebComService : IComService
catch (Exception ex)
{
errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
+ act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
}
+ sw.Stop();
+ FuchsTelemetry.EmailSendDuration.Record(sw.Elapsed.TotalMilliseconds,
+ new KeyValuePair("success", success));
+ if (success) FuchsTelemetry.EmailsSent.Add(1);
+ else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair("reason", "api-error"));
+ _logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms",
+ reference, success, sw.ElapsedMilliseconds);
+
await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors);
return success;
}
@@ -138,7 +155,12 @@ public class ProcessWebComService : IComService
};
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
- if (ok) return true;
+ if (ok)
+ {
+ FuchsTelemetry.SmsSent.Add(1);
+ _logger.LogInformation("SendSmsAsync sent to {Mobile}", mobile);
+ return true;
+ }
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Body}", mobile, responseBody);
return false;
diff --git a/Fuchs/Services/ReminderService.cs b/Fuchs/Services/ReminderService.cs
index ae47c0f..acebce4 100644
--- a/Fuchs/Services/ReminderService.cs
+++ b/Fuchs/Services/ReminderService.cs
@@ -1,5 +1,7 @@
using System.Data;
+using System.Diagnostics;
using Fuchs.intranet;
+using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
@@ -33,8 +35,9 @@ public class ReminderService : IReminderService
public async Task LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec)
{
+ _logger.LogDebug("LoadReminderAsync id={Id} user={User}", id, userAccountId);
var rem = new FdsReminderData();
- if (string.IsNullOrEmpty(id)) return rem;
+ if (string.IsNullOrEmpty(id)) { _logger.LogWarning("LoadReminderAsync called with empty id (user={User})", userAccountId); return rem; }
var pl = new List
{
@@ -57,7 +60,8 @@ public class ReminderService : IReminderService
public async Task RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
string userAccountId, DatabaseSecurity dbSec)
{
- if (reminder.Rem == null || reminder.Rem.Count == 0) return reminder;
+ _logger.LogInformation("RegisterReminderAsync change={Change} remId={RemId} user={User}", change, remId, userAccountId);
+ if (reminder.Rem == null || reminder.Rem.Count == 0) { _logger.LogWarning("RegisterReminderAsync: no form values supplied (user={User})", userAccountId); return reminder; }
var pl = new List
{
@@ -85,11 +89,16 @@ public class ReminderService : IReminderService
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
+ _logger.LogInformation("RegisterReminderAsync registered id={Id}", reminder.Id);
return reminder;
}
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft)
{
+ using var act = FuchsTelemetry.StartActivity("reminder.render");
+ act?.SetTag("fuchs.reminder.id", reminder.Id);
+ act?.SetTag("fuchs.reminder.draft", draft);
+ _logger.LogDebug("GenerateReminderPdf id={Id} draft={Draft}", reminder.Id, draft);
var tb = new FuchsPdf.FdsTextBlocks
{
AdminRef = "",
@@ -112,6 +121,7 @@ public class ReminderService : IReminderService
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
doc.Info.Title = reminder.ReminderTitle;
_pdf.ApplyReminder(doc, tb, reminder, draft);
+ FuchsTelemetry.RemindersRendered.Add(1, new KeyValuePair("draft", draft));
return doc;
}
diff --git a/Fuchs/appsettings.json b/Fuchs/appsettings.json
index c4897b4..479fbf9 100644
--- a/Fuchs/appsettings.json
+++ b/Fuchs/appsettings.json
@@ -38,6 +38,10 @@
"AccountId": "",
"Token": "MANAGED_BY_KEYVAULT",
"Enabled": false
+ },
+ "Telemetry": {
+ "Enabled": true,
+ "OtlpEndpoint": ""
}
}
}
\ No newline at end of file