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
@@ -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);
+6
View File
@@ -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" />
+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.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)
+8
View File
@@ -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;
}
+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 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;
}
}
}
+17
View File
@@ -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)
+9 -1
View File
@@ -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);
}
+13 -2
View File
@@ -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;
}
+8 -2
View File
@@ -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);
}
}
+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 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;
+12 -2
View File
@@ -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;
}
+4
View File
@@ -38,6 +38,10 @@
"AccountId": "",
"Token": "MANAGED_BY_KEYVAULT",
"Enabled": false
},
"Telemetry": {
"Enabled": true,
"OtlpEndpoint": ""
}
}
}