Add OpenTelemetry, performance metrics, and broaden logging + tests

Observability:
- New FuchsTelemetry (ActivitySource + Meter) defining business counters
  (invoices/reminders/reports rendered, emails/sms sent+failed, MT940 rows,
  MFR calls) and duration histograms (PDF render, report render, email send).
- Program.cs wires OpenTelemetry tracing (ASP.NET Core, HttpClient, SqlClient,
  app source) and metrics (ASP.NET Core, HttpClient, runtime, app meter).
  OTLP export is enabled only when Fuchs:Telemetry:OtlpEndpoint is set, so a
  missing collector never affects the app; disable via Fuchs:Telemetry:Enabled.

Instrumentation + logging:
- Services (Pdf, Invoice, Reminder, Report, Com, Banking, Widget, MfrFactory)
  now emit spans, record metrics, and log entry/result/timing/errors.
- Added dispatch + key-action logging to the previously silent handlers
  (Banking, Reminder, Reports, Requests).

Tests (137 total, +10):
- ProcessWebComServiceTests with a stub HttpMessageHandler cover success
  (API 200), failure (API 500, invalid email, empty mobile), disabled mode,
  the base64 attachment payload contract, and metric emission via MeterListener.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 14:02:13 +02:00
parent 8dee630abb
commit e04d590c3a
17 changed files with 473 additions and 16 deletions
+194
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<HttpResponseMessage> 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<ProcessWebComService>.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", "<p>hi</p>", "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", "<p>hi</p>", "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", "<p>hi</p>", 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", "<p>hi</p>", "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<string, byte[]> { ["Rechnung.pdf"] = pdf };
bool result = await svc.SendEmailAsync("inv_5", "Subject", "<p>hi</p>", "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<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start();
var svc = CreateService(new StubHandler(HttpStatusCode.OK));
await svc.SendEmailAsync("inv_metric", "S", "<p>x</p>", "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";
}
@@ -15,12 +15,15 @@ public partial class IntranetController
{ {
private async Task<IActionResult> Do_Process_Bankings(string fn, string id, string code) private async Task<IActionResult> Do_Process_Bankings(string fn, string id, string code)
{ {
_logger.LogDebug("Do_Process_Bankings action={Action} user={User}", id, UserAccountID);
switch (id.ToLower()) switch (id.ToLower())
{ {
case "auth": case "auth":
return await JSONAsync(new { manage = 1 }); return await JSONAsync(new { manage = 1 });
case "up": case "up":
_logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}",
Request.Form.Files.Count, UserAccountID);
foreach (var fle in Request.Form.Files) foreach (var fle in Request.Form.Files)
{ {
using var stream = fle.OpenReadStream(); using var stream = fle.OpenReadStream();
@@ -50,6 +53,8 @@ public partial class IntranetController
dtwa.OnCommandAfterError += (_, exc) => dtwa.OnCommandAfterError += (_, exc) =>
_intranet.debug_log("IntranetController.bam.up - command-after exception", _intranet.debug_log("IntranetController.bam.up - command-after exception",
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl }); 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(); dtwa.DoSubmit();
} }
return Ok(); return Ok();
@@ -17,6 +17,7 @@ public partial class IntranetController
{ {
private async Task<IActionResult> Do_Process_Reminder(string fn, string id, string code) private async Task<IActionResult> Do_Process_Reminder(string fn, string id, string code)
{ {
_logger.LogDebug("Do_Process_Reminder action={Action} user={User}", id, UserAccountID);
switch (id.ToLower()) switch (id.ToLower())
{ {
case "get": case "get":
@@ -117,6 +118,8 @@ public partial class IntranetController
frdic.no("InvoiceFile", null!) is byte[] invFile) frdic.no("InvoiceFile", null!) is byte[] invFile)
remdoc[frdic.nz("InvoiceFileName")] = 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( bool sent = await _comService.SendEmailAsync(
$"inv_{remId}", $"inv_{remId}",
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}", $"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
@@ -13,6 +13,7 @@ public partial class IntranetController
{ {
private async Task<IActionResult> Do_Process_Reports(string fn, string id, string code) private async Task<IActionResult> 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()) switch (id.ToLower())
{ {
case "auth": case "auth":
@@ -19,6 +19,7 @@ public partial class IntranetController
{ {
private async Task<IActionResult> Do_Process_Requests(string fn, string id, string code) private async Task<IActionResult> Do_Process_Requests(string fn, string id, string code)
{ {
_logger.LogDebug("Do_Process_Requests action={Action} user={User}", id, UserAccountID);
switch (id.ToLower()) switch (id.ToLower())
{ {
case "auth": case "auth":
@@ -277,6 +278,8 @@ public partial class IntranetController
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0)); 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 terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
string body = BuildInvoiceBody(bal, terms); 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( bool sent = await _comService.SendEmailAsync(
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}", $"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
body, email.Trim(), "", inv); body, email.Trim(), "", inv);
+6
View File
@@ -33,6 +33,12 @@
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="MailKit" Version="4.17.0" /> <PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.15.2" />
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" /> <PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
<PackageReference Include="QRCoder" Version="1.8.0" /> <PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" /> <PackageReference Include="PDFsharp" Version="6.2.4" />
+56
View File
@@ -0,0 +1,56 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace Fuchs.Observability;
/// <summary>
/// Central definition of the application's OpenTelemetry instrumentation:
/// a single <see cref="ActivitySource"/> for tracing and a single
/// <see cref="Meter"/> with the business / performance instruments.
///
/// These use the in-box <c>System.Diagnostics</c> APIs, which the OpenTelemetry
/// SDK (wired up in <c>Program.cs</c>) collects and exports. Code can therefore
/// emit spans and metrics without taking a direct dependency on the OTel SDK.
/// </summary>
public static class FuchsTelemetry
{
public const string ServiceName = "Fuchs.Intranet";
public static readonly string ServiceVersion =
typeof(FuchsTelemetry).Assembly.GetName().Version?.ToString() ?? "1.0.0";
/// <summary>Tracing source — register this name with the tracer provider.</summary>
public static readonly ActivitySource ActivitySource = new(ServiceName, ServiceVersion);
/// <summary>Metrics meter — register this name with the meter provider.</summary>
public static readonly Meter Meter = new(ServiceName, ServiceVersion);
// ── Business counters ────────────────────────────────────────────────────
public static readonly Counter<long> InvoicesRendered =
Meter.CreateCounter<long>("fuchs.invoices.rendered", "{invoice}", "Number of invoice PDFs rendered.");
public static readonly Counter<long> RemindersRendered =
Meter.CreateCounter<long>("fuchs.reminders.rendered", "{reminder}", "Number of reminder PDFs rendered.");
public static readonly Counter<long> ReportsRendered =
Meter.CreateCounter<long>("fuchs.reports.rendered", "{report}", "Number of reports rendered (by function).");
public static readonly Counter<long> EmailsSent =
Meter.CreateCounter<long>("fuchs.emails.sent", "{email}", "Number of emails accepted by the mailer API.");
public static readonly Counter<long> EmailsFailed =
Meter.CreateCounter<long>("fuchs.emails.failed", "{email}", "Number of emails that failed to send.");
public static readonly Counter<long> SmsSent =
Meter.CreateCounter<long>("fuchs.sms.sent", "{sms}", "Number of SMS messages sent.");
public static readonly Counter<long> Mt940RowsParsed =
Meter.CreateCounter<long>("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed.");
public static readonly Counter<long> MfrCalls =
Meter.CreateCounter<long>("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated.");
// ── Performance histograms (durations in milliseconds) ───────────────────
public static readonly Histogram<double> PdfRenderDuration =
Meter.CreateHistogram<double>("fuchs.pdf.render.duration", "ms", "PDF render duration.");
public static readonly Histogram<double> ReportRenderDuration =
Meter.CreateHistogram<double>("fuchs.report.render.duration", "ms", "Report render duration.");
public static readonly Histogram<double> EmailSendDuration =
Meter.CreateHistogram<double>("fuchs.email.send.duration", "ms", "Email send round-trip duration.");
/// <summary>Starts a span on the application's <see cref="ActivitySource"/>.</summary>
public static Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) =>
ActivitySource.StartActivity(name, kind);
}
+36
View File
@@ -1,5 +1,6 @@
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Logging; using Fuchs.Logging;
using Fuchs.Observability;
using OCORE_web.Secrets; using OCORE_web.Secrets;
using Fuchs.Services; using Fuchs.Services;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
@@ -8,6 +9,9 @@ using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using OCORE.security; using OCORE.security;
using OCORE.web; using OCORE.web;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Fuchs; namespace Fuchs;
@@ -84,6 +88,38 @@ public class Program
builder.Services.AddScoped<IReportService, FuchsReportService>(); builder.Services.AddScoped<IReportService, FuchsReportService>();
builder.Services.AddScoped<IInvoiceService, InvoiceService>(); builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddScoped<IReminderService, ReminderService>(); builder.Services.AddScoped<IReminderService, ReminderService>();
// ── 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) private static void ConfigureApp(WebApplication app)
+8
View File
@@ -1,4 +1,6 @@
using System.Data; using System.Data;
using System.Diagnostics;
using Fuchs.Observability;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using programmersdigest.MT940Parser; using programmersdigest.MT940Parser;
@@ -27,6 +29,8 @@ public class BankingService : IBankingService
public DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null) 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(); var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
void SetNfo(DataRow nr, string key, object? value) void SetNfo(DataRow nr, string key, object? value)
@@ -94,6 +98,10 @@ public class BankingService : IBankingService
} }
tbl.AcceptChanges(); 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; return tbl;
} }
+74 -7
View File
@@ -1,4 +1,6 @@
using Fuchs.intranet; using System.Diagnostics;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel;
@@ -6,7 +8,8 @@ namespace Fuchs.Services;
/// <summary> /// <summary>
/// PDF service implementation. Delegates to <see cref="FuchsPdf"/> static methods /// PDF service implementation. Delegates to <see cref="FuchsPdf"/> static methods
/// while providing a DI-friendly injectable wrapper. /// while providing a DI-friendly injectable wrapper, structured logging and
/// render-duration metrics.
/// </summary> /// </summary>
public class FuchsPdfService : IPdfService public class FuchsPdfService : IPdfService
{ {
@@ -16,30 +19,94 @@ public class FuchsPdfService : IPdfService
{ {
_logger = logger; _logger = logger;
FuchsPdf.SetLicense(); FuchsPdf.SetLicense();
_logger.LogDebug("FuchsPdfService initialised (PDF license applied).");
} }
public Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft) public Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft)
{ {
_logger.LogDebug("WriteLetterAsync draft={Draft}", draft);
return FuchsPdf.WriteLetter(textBlocks, draft, FuchsPdf.DeCulture); return FuchsPdf.WriteLetter(textBlocks, draft, FuchsPdf.DeCulture);
} }
public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsInvoiceData invoice, bool draft = false) FdsInvoiceData invoice, bool draft = false)
{ {
_logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft);
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft); FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft);
} }
public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks, public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsReminderData reminder, bool draft = false) FdsReminderData reminder, bool draft = false)
{ {
_logger.LogDebug("ApplyReminder id={Id} draft={Draft}", reminder.Id, draft);
FuchsPdf.ApplyReminder(doc, textBlocks, reminder, 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<string, object?>("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<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc) => public async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc)
FuchsPdf.DocToImageCollection(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<string, object?>("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<OCORE.pdf._pdf.ImageCollection> BytesToImageCollectionAsync(byte[] pdfBytes) => public async Task<OCORE.pdf._pdf.ImageCollection> BytesToImageCollectionAsync(byte[] pdfBytes)
FuchsPdf.BytesToImageCollection(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<string, object?>("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;
}
}
} }
+17
View File
@@ -1,4 +1,6 @@
using System.Diagnostics;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -69,6 +71,12 @@ public class FuchsReportService : IReportService
} }
bool ciForce = prms.TryGetValue("cache", out var ca) && ca.ToLower() == "0"; 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 try
{ {
switch (tgt) switch (tgt)
@@ -119,9 +127,18 @@ public class FuchsReportService : IReportService
} }
catch (Exception ex) catch (Exception ex)
{ {
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Report processing failed fnc={Fnc} report={Report} tgt={Tgt}", fnc, report, tgt); _logger.LogError(ex, "Report processing failed fnc={Fnc} report={Report} tgt={Tgt}", fnc, report, tgt);
return new StatusCodeResult(500); return new StatusCodeResult(500);
} }
finally
{
sw.Stop();
FuchsTelemetry.ReportRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("fn", tgt));
FuchsTelemetry.ReportsRendered.Add(1, new KeyValuePair<string, object?>("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<string, string> prms, int ciRefresh) private static void ApplyReload(FuchsHtmlPage page, IDictionary<string, string> prms, int ciRefresh)
+9 -1
View File
@@ -1,4 +1,5 @@
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -31,17 +32,24 @@ public class FuchsWidgetService : IWidgetService
public async Task<IActionResult> GetWidgetAsync(string widgetId, string userAccountId, public async Task<IActionResult> GetWidgetAsync(string widgetId, string userAccountId,
DatabaseSecurity dbSec, HttpRequest request) 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 try
{ {
return widgetId.ToLower() switch var result = widgetId.ToLower() switch
{ {
"my" => await HandleWidgetMy(userAccountId, dbSec), "my" => await HandleWidgetMy(userAccountId, dbSec),
"one" => await HandleWidgetOne(userAccountId, dbSec, request), "one" => await HandleWidgetOne(userAccountId, dbSec, request),
_ => await HandleWidgetGeneric(widgetId, userAccountId, dbSec) _ => await HandleWidgetGeneric(widgetId, userAccountId, dbSec)
}; };
_logger.LogDebug("GetWidgetAsync widget={WidgetId} result={Result} user={User}",
widgetId, result.GetType().Name, userAccountId);
return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
act?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Widget error for {WidgetId}, user {UserAccountId}", widgetId, userAccountId); _logger.LogError(ex, "Widget error for {WidgetId}, user {UserAccountId}", widgetId, userAccountId);
return new StatusCodeResult(500); return new StatusCodeResult(500);
} }
+13 -2
View File
@@ -1,5 +1,7 @@
using System.Data; using System.Data;
using System.Diagnostics;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel;
@@ -33,8 +35,9 @@ public class InvoiceService : IInvoiceService
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec) public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
{ {
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
var inv = new FdsInvoiceData(); 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<SqlParameter> var pl = new List<SqlParameter>
{ {
@@ -51,13 +54,15 @@ public class InvoiceService : IInvoiceService
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary()); inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true; inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft}", inv.Id, inv.IsDraft);
return inv; return inv;
} }
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec) 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<SqlParameter> { SQL_VarChar("@authuser", userAccountId) }; var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
pl.AddRange(invoice.BuildInvoiceParams(change, invId)); pl.AddRange(invoice.BuildInvoiceParams(change, invId));
@@ -88,11 +93,16 @@ public class InvoiceService : IInvoiceService
_logger.LogError("RegisterInvoiceAsync sql exception: {Ex}", invdset.Exception); _logger.LogError("RegisterInvoiceAsync sql exception: {Ex}", invdset.Exception);
invoice.InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary()); invoice.InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
_logger.LogInformation("RegisterInvoiceAsync registered id={Id} (change={Change})", invoice.Id, change);
return invoice; return invoice;
} }
public Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft) 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 reg = invoice.InvoiceRegistration;
var tb = new FuchsPdf.FdsTextBlocks var tb = new FuchsPdf.FdsTextBlocks
{ {
@@ -108,6 +118,7 @@ public class InvoiceService : IInvoiceService
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult(); var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
doc.Info.Title = reg?.getString("InvoiceTitle") ?? ""; doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
_pdf.ApplyInvoice(doc, tb, invoice, draft); _pdf.ApplyInvoice(doc, tb, invoice, draft);
FuchsTelemetry.InvoicesRendered.Add(1, new KeyValuePair<string, object?>("draft", draft));
return doc; return doc;
} }
+8 -2
View File
@@ -1,22 +1,28 @@
using Microsoft.Extensions.Logging; using Fuchs.Observability;
using Microsoft.Extensions.Logging;
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <summary>
/// Factory implementation for <see cref="fds.FdsMfrClient"/>. /// Factory implementation for <see cref="fds.FdsMfrClient"/>.
/// 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.
/// </summary> /// </summary>
public class MfrClientFactory : IMfrClientFactory public class MfrClientFactory : IMfrClientFactory
{ {
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<MfrClientFactory> _logger;
public MfrClientFactory(ILoggerFactory loggerFactory) public MfrClientFactory(ILoggerFactory loggerFactory)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<MfrClientFactory>();
} }
public fds.FdsMfrClient Create() public fds.FdsMfrClient Create()
{ {
FuchsTelemetry.MfrCalls.Add(1);
_logger.LogDebug("Creating new FdsMfrClient instance.");
return new fds.FdsMfrClient(_loggerFactory); return new fds.FdsMfrClient(_loggerFactory);
} }
} }
+24 -2
View File
@@ -1,6 +1,8 @@
using System.Net.Http.Headers; using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text; using System.Text;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -43,9 +45,13 @@ public class ProcessWebComService : IComService
public async Task<bool> SendEmailAsync(string reference, string subject, string html, public async Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments = null) string email, string name, Dictionary<string, byte[]>? attachments = null)
{ {
using var act = FuchsTelemetry.StartActivity("email.send");
act?.SetTag("fuchs.email.ref", reference);
if (!IsValidEmail(email)) if (!IsValidEmail(email))
{ {
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference); _logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
act?.SetStatus(ActivityStatusCode.Error, "invalid-email");
return false; return false;
} }
@@ -57,12 +63,14 @@ public class ProcessWebComService : IComService
"[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}", "[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}",
reference, subject, email); reference, subject, email);
await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled email not sent"]); await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled email not sent"]);
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "disabled"));
return false; return false;
} }
bool success = false; bool success = false;
string messageId = ""; string messageId = "";
var errors = new List<string>(); var errors = new List<string>();
var sw = Stopwatch.StartNew();
try try
{ {
@@ -105,9 +113,18 @@ public class ProcessWebComService : IComService
catch (Exception ex) catch (Exception ex)
{ {
errors.Add("Beim Versenden ist ein Fehler aufgetreten."); errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference); _logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
} }
sw.Stop();
FuchsTelemetry.EmailSendDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("success", success));
if (success) FuchsTelemetry.EmailsSent.Add(1);
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("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); await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors);
return success; return success;
} }
@@ -138,7 +155,12 @@ public class ProcessWebComService : IComService
}; };
var (ok, responseBody) = await PostToApiAsync("push_com", payload); 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); _logger.LogWarning("SendSmsAsync API error for {Mobile}: {Body}", mobile, responseBody);
return false; return false;
+12 -2
View File
@@ -1,5 +1,7 @@
using System.Data; using System.Data;
using System.Diagnostics;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel;
@@ -33,8 +35,9 @@ public class ReminderService : IReminderService
public async Task<FdsReminderData> LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec) public async Task<FdsReminderData> LoadReminderAsync(string id, string userAccountId, DatabaseSecurity dbSec)
{ {
_logger.LogDebug("LoadReminderAsync id={Id} user={User}", id, userAccountId);
var rem = new FdsReminderData(); 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<SqlParameter> var pl = new List<SqlParameter>
{ {
@@ -57,7 +60,8 @@ public class ReminderService : IReminderService
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
string userAccountId, DatabaseSecurity dbSec) 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<SqlParameter> var pl = new List<SqlParameter>
{ {
@@ -85,11 +89,16 @@ public class ReminderService : IReminderService
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception); _logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary()); reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
_logger.LogInformation("RegisterReminderAsync registered id={Id}", reminder.Id);
return reminder; return reminder;
} }
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft) 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 var tb = new FuchsPdf.FdsTextBlocks
{ {
AdminRef = "", AdminRef = "",
@@ -112,6 +121,7 @@ public class ReminderService : IReminderService
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult(); var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
doc.Info.Title = reminder.ReminderTitle; doc.Info.Title = reminder.ReminderTitle;
_pdf.ApplyReminder(doc, tb, reminder, draft); _pdf.ApplyReminder(doc, tb, reminder, draft);
FuchsTelemetry.RemindersRendered.Add(1, new KeyValuePair<string, object?>("draft", draft));
return doc; return doc;
} }
+4
View File
@@ -38,6 +38,10 @@
"AccountId": "", "AccountId": "",
"Token": "MANAGED_BY_KEYVAULT", "Token": "MANAGED_BY_KEYVAULT",
"Enabled": false "Enabled": false
},
"Telemetry": {
"Enabled": true,
"OtlpEndpoint": ""
} }
} }
} }