Parallelize invoice image fetch, add telemetry, generalize HtmlConverter

- InvoiceService.ResolveInlineImagesAsync now fetches an invoice's
  distinct whitelisted images concurrently (SemaphoreSlim-capped at 4)
  instead of one at a time. It ran inline on every invoice render,
  including repeated draft-preview renders, so N sequential mfr-portal
  round-trips could otherwise add N x latency to a user-facing request.
- Add fuchs.invoice.inlineimages.fetches (counter, tagged by outcome:
  success/http_error/bad_content_type/oversized/exception) and
  fuchs.invoice.inlineimages.resolve.duration (histogram) to
  FuchsTelemetry, matching the project's observability convention for
  outbound-HTTP operations.
- MigraDocExtensions.HtmlConverter's pre-resolved-image parameter is
  renamed inlineImages -> resolvedImages with generic docs: the
  converter fetches nothing and knows nothing about invoices — which
  images to resolve, and any whitelist/network policy, is entirely the
  caller's concern (InvoiceService, ADR 0018 today; any future caller
  tomorrow).
- Add tests asserting fetches actually overlap (not just faster) and
  that the new counter/histogram are recorded.
- Update ADR 0018 to describe the concurrent fetch, telemetry, and the
  generalized converter parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 21:17:09 +02:00
co-authored by Claude Sonnet 5
parent 406c6f2860
commit f407341f5f
5 changed files with 177 additions and 45 deletions
+58 -28
View File
@@ -30,6 +30,7 @@ public class InvoiceService : IInvoiceService
private const int MaxInlineImageBytes = 5 * 1024 * 1024;
private const int MaxInlineImagesPerInvoice = 20;
private const int MaxConcurrentInlineImageFetches = 4;
private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdf;
@@ -344,39 +345,68 @@ public class InvoiceService : IInvoiceService
}
if (urls.Count == 0) return result;
using var act = FuchsTelemetry.StartActivity("invoice.inlineimages.resolve");
act?.SetTag("fuchs.invoice.id", invoice.Id);
act?.SetTag("fuchs.inlineimages.requested", urls.Count);
var sw = Stopwatch.StartNew();
var client = _httpClientFactory.CreateClient(InlineImageHttpClientName);
foreach (var url in urls)
// Fetches run concurrently (capped) rather than one-at-a-time: this executes inline on every
// invoice render, including repeated draft-preview renders, so N sequential round-trips to the
// mfr portal would otherwise add N x latency to a user-facing request.
using var gate = new SemaphoreSlim(MaxConcurrentInlineImageFetches);
var fetches = urls.Select(async url =>
{
try
{
using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} returned {Status}", url, (int)resp.StatusCode);
continue;
}
string contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} returned non-image content-type '{ContentType}'", url, contentType);
continue;
}
var bytes = await resp.Content.ReadAsByteArrayAsync();
if (bytes.Length == 0 || bytes.Length > MaxInlineImageBytes)
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} image size {Size} out of bounds", url, bytes.Length);
continue;
}
result[url] = FuchsPdf.MigraDocFilenameFromByteArray(bytes);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ResolveInlineImagesAsync: failed to fetch invoice image {Url}", url);
}
}
await gate.WaitAsync();
try { return (url, source: await FetchInlineImageAsync(client, url)); }
finally { gate.Release(); }
});
foreach (var (url, source) in await Task.WhenAll(fetches))
if (source != null) result[url] = source;
act?.SetTag("fuchs.inlineimages.resolved", result.Count);
FuchsTelemetry.InvoiceInlineImagesResolveDuration.Record(sw.Elapsed.TotalMilliseconds);
return result;
}
/// <summary>Fetches and validates one whitelisted invoice image; null on any failure (logged +
/// counted, never thrown) so one bad image never fails the whole render.</summary>
private async Task<string?> FetchInlineImageAsync(HttpClient client, string url)
{
try
{
using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} returned {Status}", url, (int)resp.StatusCode);
FuchsTelemetry.InvoiceInlineImageFetches.Add(1, new KeyValuePair<string, object?>("outcome", "http_error"));
return null;
}
string contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} returned non-image content-type '{ContentType}'", url, contentType);
FuchsTelemetry.InvoiceInlineImageFetches.Add(1, new KeyValuePair<string, object?>("outcome", "bad_content_type"));
return null;
}
var bytes = await resp.Content.ReadAsByteArrayAsync();
if (bytes.Length == 0 || bytes.Length > MaxInlineImageBytes)
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} image size {Size} out of bounds", url, bytes.Length);
FuchsTelemetry.InvoiceInlineImageFetches.Add(1, new KeyValuePair<string, object?>("outcome", "oversized"));
return null;
}
FuchsTelemetry.InvoiceInlineImageFetches.Add(1, new KeyValuePair<string, object?>("outcome", "success"));
return FuchsPdf.MigraDocFilenameFromByteArray(bytes);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ResolveInlineImagesAsync: failed to fetch invoice image {Url}", url);
FuchsTelemetry.InvoiceInlineImageFetches.Add(1, new KeyValuePair<string, object?>("outcome", "exception"));
return null;
}
}
public async Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
{
var doc = await GenerateInvoicePdfAsync(invoice, draft);