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
@@ -44,15 +44,21 @@ so a naive "download whatever `src` says" would let it reach internal/private ne
same library the `HtmlConverter` itself parses the identical text with — rather than a regex, so
a decoy attribute next to the real `src` (e.g. `data-src="..."`) can't be picked up in its place.
Each fetched image is capped at 5MB, must report an `image/*` content-type, and the invoice
fetches at most 20 distinct images; failures (unreachable, wrong type, oversized) are logged and
just leave that one image out — they never fail the whole render.
fetches at most 20 distinct images; failures (unreachable, wrong type, oversized) are logged,
counted (`FuchsTelemetry.InvoiceInlineImageFetches`, tagged by outcome), and just leave that one
image out — they never fail the whole render. The distinct URLs fetch concurrently (capped at 4
in flight via a `SemaphoreSlim`, not one-at-a-time) since this runs inline on every render,
including repeated draft-preview renders — total resolve time is recorded in
`FuchsTelemetry.InvoiceInlineImagesResolveDuration`.
- The resolved `{src → "base64:..."}` map flows down as a plain `IReadOnlyDictionary<string,string>?`
parameter through `IPdfService.ApplyInvoice` → `FuchsPdf.ApplyInvoice` → `Cell.AddHtml` into the
`HtmlConverter`, whose new `img` handler only emits an image for a `src` present in that map —
it does no fetching itself and never sees the network. Image sizing mirrors the HTML: the
handler reads `width`/`height` (attribute or inline `style`, as TinyMCE's resize handles write
them) and converts CSS px → cm, capped to 7×10cm so a malformed/huge size can't blow out the
invoice table layout.
it does no fetching itself and never sees the network. The converter itself stays generic (its
constructor parameter is named `resolvedImages`, not invoice-specific) — resolving *which* images
go in the map, and any network/whitelist policy, is entirely InvoiceService's concern, not the
converter's. Image sizing mirrors the HTML: the handler reads `width`/`height` (attribute or
inline `style`, as TinyMCE's resize handles write them) and converts CSS px → cm, capped to
7×10cm so a malformed/huge size can't blow out the invoice table layout.
## Consequences
- Any future host serving invoice-editor images must be added to the whitelist deliberately (a