Overview
Rendro ships three optional adapters for common ecosystem workflows: threadline
(structured audit logging), mailglass (transactional email attachments), and
accrue (billing-document recipes). None of them are hard dependencies of Rendro.
Each adapter module is compiled only when its target library is present in your
application's own mix.exs — if the library is absent, the adapter module does not
exist and the Rendro core is entirely unaffected.
This guide walks through enabling each adapter, verifying it works end-to-end, and interpreting the failure modes your code may encounter.
Core layout semantics stay in Rendro
Wrapped flow text and pagination directives are core-library behavior, not
adapter-specific behavior. Author width-constrained flow text with
Rendro.flow/2, Rendro.block/2, and Rendro.text/2, then use
keep_together, keep_with_next, break_before, and break_after on
Rendro.Block when a document needs explicit break intent.
Adapters such as Oban workers, audit logging hooks, and mail-delivery helpers only transport or observe the document you already built. They do not add a paragraph DSL, CSS-style fragmentation, widow/orphan control, hyphenation, or adapter-local break semantics. Those scope boundaries come from the Rendro core layout contract and remain the same regardless of delivery path.
Signing
Rendro publishes one canonical signed-artifact recipe for operators:
Rendro-authored existing field -> Rendro.Sign.sign/2 via first-party
Rendro.Adapters.PyHanko -> Rendro.Sign.validate/2 / pdfsig.
doc =
Rendro.fixed([
Rendro.page(
width: 612,
height: 792,
margin_left: 72,
margin_top: 72,
blocks: [
Rendro.signature_field("customer_signature",
x: 10,
y: 20,
width: 180,
height: 48
)
]
)
])
{:ok, artifact} = Rendro.render_to_artifact(doc, deterministic: true)
{:ok, signed} =
Rendro.Sign.sign(artifact,
field: "customer_signature",
adapter: Rendro.Adapters.PyHanko,
adapter_opts: [
key: System.fetch_env!("PYHANKO_KEY_PATH"),
cert: System.fetch_env!("PYHANKO_CERT_PATH"),
passfile: System.fetch_env!("PYHANKO_PASSFILE_PATH")
]
)
{:ok, posture} = Rendro.Sign.validate(signed)
assert [%{field: "customer_signature", integrity: :valid}] = posture.signaturesKeep credentials application-owned. Signing is an artifact-stage transform over the original unsigned rendered artifact, not a render-core concern and not a prepared-artifact workflow.
This recipe proves integrity validation only. It does not prove certificate trust, viewer behavior, or compliance posture, and signed output remains non-deterministic.
Use guides/api_stability.md and priv/support_matrix.json for the canonical
support boundary vocabulary.
Oban
Rendro.Adapters.Oban.RenderWorker is an optional background-worker boundary for
teams that enqueue Rendro renders through Oban. The worker stays intentionally
narrow: it accepts a document builder module, builder args, an output path, and
an optional bounded-render "policies" map.
Job args contract
The worker consumes only these job args:
%{
"module" => "Elixir.MyApp.InvoiceDocument",
"args" => %{"invoice_id" => "inv_123"},
"output_path" => "/tmp/invoice.pdf",
"policies" => %{
"max_pages" => 10,
"max_bytes" => 2_000_000,
"timeout" => 15_000
}
}"policies" is optional. When present, the worker accepts only "max_pages",
"max_bytes", and "timeout". Unknown policy keys fail fast with a typed worker
error tuple instead of being silently ignored.
Document-authored policies remain canonical. Worker-provided policies fill only
missing doc.options[:policies] keys and never silently override bounds already
set by the document builder.
The worker does not support arbitrary job-arg pass-through into
doc.options or Rendro.render/2. If you need a broader async contract, build
that normalization explicitly in your own job producer before calling Rendro.
The worker also does not accept password or protection fields in job args. Protection secrets do not belong in persisted Oban args. Persist only business identifiers in Oban args. Resolve protection secrets at execution time inside your application boundary.
The canonical protected-delivery recipe is
render_to_artifact -> Protect.password -> store/deliver. If you need protected
delivery, render the artifact in your worker and apply Rendro.Protect.password/2
in an application-owned secret boundary before storage or delivery.
That recipe is also the release-tail contract for Phoenix and Mailglass users: keep passwords out of persisted Oban args, keep protection inside your application-owned secret boundary, and let Mailglass transport already-protected artifacts rather than password material.
Worker failure diagnostics
Boundary misuse returns typed {:error, reason} tuples without crashing the
worker process:
| Error tuple | When it occurs |
|---|---|
{:error, {:missing_worker_field, field}} | A required job arg such as "module", "args", or "output_path" is absent. |
{:error, {:invalid_worker_field, field, value}} | A required field or "policies" has the wrong shape. |
{:error, {:unknown_worker_module, module}} | The named builder module is not available at runtime. |
{:error, {:invalid_worker_module, module}} | The module exists but does not export build_document/1. |
{:error, {:unknown_worker_policy, key}} | "policies" contains a key outside the supported bounded-render surface. |
{:error, {:invalid_worker_policy, key, value}} | A supported policy key is present with an invalid value. |
If the worker boundary succeeds but the render itself fails, the worker returns
the underlying Rendro reason atom (for example :max_pages_exceeded,
:max_bytes_exceeded, or :timeout).
Threadline
Rendro.Adapters.Threadline funnels Rendro render lifecycle events into
Threadline.record_action/2 so every render (success or failure) is captured in
your Threadline audit trail.
Setup
Add
threadlineto your application'smix.exs:defp deps do [ {:rendro, "~> 0.1"}, {:threadline, "~> 0.2"}, # ... ] endAttach the handler once at application start (e.g. from
Application.start/2):defmodule MyApp.Application do use Application def start(_type, _args) do Rendro.Adapters.Threadline.attach() # ... supervise children end endattach/0is idempotent — calling it more than once returns:okwithout registering a duplicate handler.
The adapter subscribes to:
[:rendro, :render, :stop]— emitted after every render, successful or not.[:rendro, :render, :exception]— emitted when the render pipeline crashes.
On :stop with status: :ok it records Threadline.record_action(:render_succeeded, metadata).
On :stop with status: :error or on :exception it records Threadline.record_action(:render_failed, metadata).
The metadata forwarded to Threadline contains only the allowlisted telemetry keys
(:render_id, :stage, :status, :page_count, :byte_size, :duration,
:document_type, :deterministic) plus the nested :error map on failed
renders. Document bodies, attachment binaries, and rendered PDFs are never
included.
Verification
After attaching the handler, render a document and confirm the audit row arrived:
# docs-contract: integrations-threadline-happy-path
Rendro.Adapters.Threadline.attach()
{:ok, _pdf} = Rendro.render(
Rendro.flow([Rendro.block(Rendro.text("Test invoice", size: 12))])
)
Rendro.Adapters.Threadline.detach()The failure-path example below is intentionally schematic. Its public contract is pinned by direct ExUnit semantic tests instead of by a compile-only docs lane.
doc = Rendro.flow(
[Rendro.block(Rendro.text("x", size: 12))],
options: %{policies: [max_pages: 0]}
)
{:error, %Rendro.Error{reason: :max_pages_exceeded}} = Rendro.render(doc)
[action | _] = Threadline.list_actions()
assert action.action == :render_failedTo detach the handler (e.g. in test teardown):
Rendro.Adapters.Threadline.detach()Failure diagnostics
Rendro.Adapters.Threadline.track_render/2 (invoked internally by the telemetry
handler) can return the following values:
| Return value | When it occurs | What to do |
|---|---|---|
:ok | Threadline.record_action/2 returned :ok or {:ok, _}. | Normal; audit row recorded. |
{:error, term()} | Threadline.record_action/2 returned {:error, reason}. | Inspect reason; the Threadline backend declined to record. Check Threadline logs. |
{:error, {:unexpected_return, term()}} | Threadline.record_action/2 returned something other than :ok, {:ok, _}, or {:error, _}. | The Threadline library returned an unexpected shape. Check the Threadline version and its changelog. |
{:error, {:exception, Exception.t()}} | Threadline.record_action/2 raised an exception; the adapter rescues and wraps it. | Inspect the exception struct. Check connectivity/auth to the Threadline backend. |
Note: the audit handler is invoked asynchronously from the render pipeline. A
non-:ok return from track_render/2 does NOT fail the render — callers still
receive their {:ok, pdf} or {:error, %Rendro.Error{}}. If you require
guaranteed audit delivery, add monitoring on Threadline's storage directly.
Timeouts are recorded through the same failed-render path as other errors.
Rendro.Pipeline.run/1 emits a top-level [:rendro, :render, :stop] event on
timeout with status: :error and nested error.kind: :timeout, so Threadline
records :render_failed and preserves the timeout subtype in metadata.
That means operators can query one failure surface (:render_failed) and then
filter by the nested timeout classification instead of handling a timeout-only
action family.
Mailglass
Rendro.Adapters.Mailglass attaches rendered PDF documents to Swoosh emails or
Mailglass.Message structs, enabling end-to-end transactional email workflows
without leaving the Rendro boundary.
Setup
Add mailglass and swoosh to your application's mix.exs:
defp deps do
[
{:rendro, "~> 0.1"},
{:mailglass, "~> 0.1"},
{:swoosh, "~> 1.0"},
# ...
]
endThe canonical pipeline is schematic because delivery depends on your own mailer module and deployment setup.
email =
Swoosh.Email.new()
|> Swoosh.Email.to("customer@example.test")
|> Swoosh.Email.subject("Your invoice")
email_with_attachment =
Rendro.Adapters.Mailglass.attach_pdf(email, doc, "invoice.pdf")
MyApp.Mailer.deliver(email_with_attachment)Or in a pipe:
email
|> Rendro.Adapters.Mailglass.attach_pdf(doc, "invoice.pdf")
|> case do
%Swoosh.Email{} = email_with_pdf -> MyApp.Mailer.deliver(email_with_pdf)
{:error, reason} -> handle_error(reason)
endIf your workflow needs password-to-open delivery, protect the artifact first and
then hand the protected artifact to attach_artifact/3:
{:ok, artifact} =
doc
|> Rendro.render_to_artifact(deterministic: true)
|> then(fn {:ok, artifact} ->
Rendro.Protect.password(artifact,
open_password: System.fetch_env!("PDF_OPEN_PASSWORD"),
owner_password: System.fetch_env!("PDF_OWNER_PASSWORD"),
advisory_permissions: [:print],
adapter: Rendro.Adapters.Qpdf
)
end)
email_with_attachment =
Rendro.Adapters.Mailglass.attach_artifact(email, artifact, "invoice.pdf")Protected delivery uses Rendro.Adapters.Mailglass.attach_artifact/3 with an already-protected %Rendro.Artifact{}.
That flow keeps protection at the artifact boundary. Mailglass does not need to know the passwords; it just transports the already-protected PDF bytes.
Verification
Swoosh email path:
# docs-contract: integrations-mailglass-swoosh
doc = Rendro.flow([Rendro.block(Rendro.text("Invoice #001", size: 12))])
email = Swoosh.Email.new() |> Swoosh.Email.to("test@example.test")
result = Rendro.Adapters.Mailglass.attach_pdf(email, doc, "invoice.pdf")
# Confirm the attachment was added
assert length(result.attachments) == 1
[attachment | _] = result.attachments
assert attachment.content_type == "application/pdf"
assert attachment.filename == "invoice.pdf"
assert {:data, pdf} = attachment.data
assert binary_part(pdf, 0, 4) == "%PDF"Mailglass.Message path:
When the first argument is a %Mailglass.Message{}, attach_pdf/3 extracts the
underlying Swoosh email, attaches the PDF to it, and re-wraps the result using
Mailglass.Message.update_swoosh/2 if that function is exported:
# docs-contract: integrations-mailglass-message
doc = Rendro.flow([Rendro.block(Rendro.text("Invoice #001", size: 12))])
message = %Mailglass.Message{swoosh: Swoosh.Email.new(), meta: %{campaign_id: "abc"}}
updated_message = Rendro.Adapters.Mailglass.attach_pdf(message, doc, "invoice.pdf")
# The Mailglass wrapper is preserved
assert is_struct(updated_message, Mailglass.Message)
assert length(updated_message.swoosh.attachments) == 1Failure diagnostics
attach_pdf/3 never raises. All failure paths return {:error, _}:
| Error tuple | When it occurs | What to check |
|---|---|---|
{:error, %Rendro.Error{reason: {:invalid_email_target, value}}} | The first argument is neither a %Swoosh.Email{} nor a recognized Mailglass message struct. value echoes the caller's input back for inspection. | Ensure the first argument is a %Swoosh.Email{} or a %Mailglass.Message{} (or a struct whose module name ends in .Message and exports update_swoosh/2). Do not pass bare maps, atoms, or other types. |
{:error, {:unrecognized_message_shape, struct_module}} | The first argument passes the Mailglass-message check (struct ending in .Message exporting update_swoosh/2) but has neither a :swoosh nor an :email field holding a %Swoosh.Email{}. struct_module names the offending struct module. | Inspect the custom Mailglass-style struct: it must carry its Swoosh email in a field named :swoosh or :email. If it uses a different field name, implement update_swoosh/2 in a way that reads from that field, or pre-extract the Swoosh email before calling attach_pdf/3. |
{:error, %Rendro.Error{}} | The document rendering step failed (empty document, max-pages/bytes policy violation, timeout, validation errors, etc.). Inspect :stage and :reason on the %Rendro.Error{}. | Check the :stage field (:build, :compose, :measure, :paginate, :render) to locate where the pipeline failed. Check :reason for the specific failure kind (e.g. :max_pages_exceeded, :max_bytes_exceeded, :timeout). Adjust document content or policies accordingly. |
Accrue
Rendro.Adapters.Accrue is a billing-document recipe that transforms an
%Accrue.Invoice{} into a %Rendro.Document{} ready to be passed to
Rendro.render/1. The recipe is pure and composable — it does not render,
it only builds the document structure.
Setup
Add accrue to your application's mix.exs:
defp deps do
[
{:rendro, "~> 0.1"},
{:accrue, "~> 0.3"},
# ...
]
endThe recipe entrypoint is pure, but this high-level application example is
schematic because MyApp.Billing.fetch_invoice!/1 is app-specific.
invoice = MyApp.Billing.fetch_invoice!(invoice_id)
{:ok, doc} = Rendro.Adapters.Accrue.recipe(invoice)
{:ok, pdf} = Rendro.render(doc)Recipe contract
recipe/1 reads the following fields from the %Accrue.Invoice{}:
| Field | Usage |
|---|---|
:id | Rendered as "INVOICE #<id>" in the document header. |
:customer | .name field extracted for "Bill to: <name>" in the header. |
:line_items | List of %Accrue.LineItem{} mapped into a table with explicit column rules for Description, Qty, Unit, and Subtotal. Table layout semantics remain core-library behavior. |
:total | Rendered as "Total: $<total>" beneath the line-items table. |
:issued_at | Rendered as "Issued: <date>" in the header. |
%Accrue.LineItem{} fields consumed:
| Field | Usage |
|---|---|
:description | Table row — Description column. |
:quantity | Table row — Qty column. |
:unit_amount | Table row — Unit column. |
:subtotal | Table row — Subtotal column. |
The recipe is the minimum useful mapping. Teams wanting different layouts,
additional fields, custom styling, or multi-section documents should treat
Rendro.Adapters.Accrue.recipe/1 as a starting template — copy it into your own
module and customize from there.
Verification
After calling recipe/1, render the document and verify the output:
# docs-contract: integrations-accrue-verification
invoice = %Accrue.Invoice{
id: "INV-001",
issued_at: ~D[2026-04-26],
customer: %{name: "Acme Corp"},
line_items: [
%Accrue.LineItem{description: "Widget", quantity: 2, unit_amount: 50, subtotal: 100}
],
total: 100
}
{:ok, doc} = Rendro.Adapters.Accrue.recipe(invoice)
{:ok, pdf} = Rendro.render(doc)
# PDF magic bytes confirm a valid PDF was produced
assert binary_part(pdf, 0, 4) == "%PDF"
# Inspect the document to confirm the invoice id is present
assert inspect(doc) =~ "INV-001"Failure diagnostics
recipe/1 can return the following errors:
| Error tuple | When it occurs | What to check |
|---|---|---|
{:error, {:invalid_invoice, term()}} | The argument is not an %Accrue.Invoice{} struct. The second element echoes the caller's input for inspection. | Ensure the input is an %Accrue.Invoice{} fetched from the Accrue library. Do not pass plain maps, keyword lists, or other structs. |
Render-time errors flow through Rendro.render/1 (not recipe/1) and produce
{:error, %Rendro.Error{}} with :stage in one of :build, :compose,
:measure, :paginate, or :render. Inspect the %Rendro.Error{} fields for
detail:
| Field | Meaning |
|---|---|
:stage | Pipeline stage where the failure occurred. |
:reason | Structured reason atom or tuple (e.g. :max_pages_exceeded, :timeout). |
Optional-dependency discipline
None of threadline, mailglass, or accrue appear in Rendro's own mix.exs
dependencies. Each adapter module is wrapped in a compile-time guard:
if Code.ensure_loaded?(Threadline) do
defmodule Rendro.Adapters.Threadline do
def attach, do: :ok
end
endWhen the library is absent from the application's deps, the guard evaluates to
false at compile time, the defmodule block is skipped entirely, and the
adapter module does not exist. Core Rendro behavior is completely unaffected.
Maintainers should NOT add :threadline, :mailglass, or :accrue to Rendro's
own mix.exs deps. Users add them to their own application's mix.exs; Rendro
simply detects their presence at compile time.
Test-time recompilation: In Rendro's own test suite, the adapter modules need
to be exercisable without adding the ecosystem libraries as real dependencies. This
is accomplished via a two-step mechanism in test/support/mocks.ex:
Minimal stub modules for
Threadline,Mailglass,Mailglass.Message,Swoosh.Email,Swoosh.Attachment,Accrue,Accrue.Invoice, andAccrue.LineItemare defined intest/support/mocks.ex. These stubs satisfyCode.ensure_loaded?/1checks during the test compile.AdapterReloader.recompile/0(called fromtest/test_helper.exsafterExUnit.start/0) re-evaluates each adapter file with the stub modules already loaded, so the guarded module bodies are compiled and available during test runs.
This design lets CI exercise all adapter code paths without any real ecosystem
library installed, preserving the "Rendro core has no ecosystem deps" guarantee
from production builds. See test/support/mocks.ex for the full stub definitions.