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:
@@ -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)
|
||||
{
|
||||
_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();
|
||||
|
||||
@@ -17,6 +17,7 @@ public partial class IntranetController
|
||||
{
|
||||
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())
|
||||
{
|
||||
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"))}",
|
||||
|
||||
@@ -13,6 +13,7 @@ public partial class IntranetController
|
||||
{
|
||||
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())
|
||||
{
|
||||
case "auth":
|
||||
|
||||
@@ -19,6 +19,7 @@ public partial class IntranetController
|
||||
{
|
||||
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())
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -33,6 +33,12 @@
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<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="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<IReportService, FuchsReportService>();
|
||||
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
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<Document> 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<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) =>
|
||||
FuchsPdf.DocToImageCollection(doc);
|
||||
public async Task<OCORE.pdf._pdf.ImageCollection> 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<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) =>
|
||||
FuchsPdf.BytesToImageCollection(pdfBytes);
|
||||
public async Task<OCORE.pdf._pdf.ImageCollection> 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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
|
||||
@@ -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<IActionResult> 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);
|
||||
}
|
||||
|
||||
@@ -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<FdsInvoiceData> 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<SqlParameter>
|
||||
{
|
||||
@@ -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<FdsInvoiceData> 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<SqlParameter> { 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<string, object?>("draft", draft));
|
||||
return doc;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public class MfrClientFactory : IMfrClientFactory
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<MfrClientFactory> _logger;
|
||||
|
||||
public MfrClientFactory(ILoggerFactory loggerFactory)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<MfrClientFactory>();
|
||||
}
|
||||
|
||||
public fds.FdsMfrClient Create()
|
||||
{
|
||||
FuchsTelemetry.MfrCalls.Add(1);
|
||||
_logger.LogDebug("Creating new FdsMfrClient instance.");
|
||||
return new fds.FdsMfrClient(_loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> SendEmailAsync(string reference, string subject, string html,
|
||||
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))
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -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<string, object?>("reason", "disabled"));
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = false;
|
||||
string messageId = "";
|
||||
var errors = new List<string>();
|
||||
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<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);
|
||||
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;
|
||||
|
||||
@@ -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<FdsReminderData> 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<SqlParameter>
|
||||
{
|
||||
@@ -57,7 +60,8 @@ public class ReminderService : IReminderService
|
||||
public async Task<FdsReminderData> 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<SqlParameter>
|
||||
{
|
||||
@@ -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<string, object?>("draft", draft));
|
||||
return doc;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"AccountId": "",
|
||||
"Token": "MANAGED_BY_KEYVAULT",
|
||||
"Enabled": false
|
||||
},
|
||||
"Telemetry": {
|
||||
"Enabled": true,
|
||||
"OtlpEndpoint": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user