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