Report Generation Architecture
Public API and report data model
A report is assembled from named HTML fragments, but Tab is not part of the public flyte.report re-exports. Import Report and the module-level helpers from flyte.report; obtain a Tab through Report.get_tab() or flyte.report.get_tab().
from flyte.report import Report
report = Report(name="metrics-task")
metrics = report.get_tab("metrics")
metrics.log("<h2>Metrics</h2>")
metrics.log("<p>Rows processed: 100</p>")
html = report.get_final_report()
Report and Tab are dataclasses implemented together in report/_report.py. A Tab has a public name and a content list initialized internally with init=False; therefore, supply content through its methods rather than as a constructor argument. log() appends one fragment, while replace() discards earlier fragments and keeps only the new one:
metrics.replace("<h2>Current metrics</h2><p>Rows processed: 125</p>")
assert metrics.get_html() == "<h2>Current metrics</h2><p>Rows processed: 125</p>"
Tab.get_html() returns "\n".join(self.content). Thus, repeated log() calls produce a newline-separated fragment stream, whereas replace() is useful for snapshot-style content. Both methods expect valid HTML that is suitable for insertion inside a <div>; Tab does not escape or validate the supplied strings.
The module-level functions provide the same model through the active task context. log(content, do_flush=False) always appends to the implicit main tab, and replace(content, do_flush=False) replaces only that tab. Named sections require get_tab():
from flyte.report import get_tab, log, replace
log("<p>Started processing</p>") # appends to main
get_tab("metrics").log("<p>Rows: 100</p>") # appends to metrics
replace("<p>Finished processing</p>") # replaces main only
These helpers are decorated with flyte.syncify.syncify, so the report module exposes synchronous and asynchronous forms; the runtime uses the asynchronous form as flush.aio().
How tabs become one HTML document
Report.__post_init__() unconditionally inserts a Tab("main") into tabs. The tabs field is a dictionary, and get_tab(name) lazily adds a Tab(name) when the name is absent. Requesting an unknown tab with create_if_missing=False raises ValueError instead:
report = Report(name="example")
main = report.get_tab("main", create_if_missing=False)
try:
report.get_tab("missing", create_if_missing=False)
except ValueError:
pass
When a caller supplies a tabs dictionary to the Report constructor, __post_init__() still assigns a newly created Tab("main") to the main key. The initial main tab is consequently the first tab in normal construction, and subsequent tabs appear in dictionary insertion order.
get_final_report() performs the aggregation in three stages:
- It calls
get_html()for every tab. - For each
(key, value)pair, it creates one navigation item and one body container:- the navigation label is produced as
<li onclick="handleLinkClick(this)">...</li>withhtml.escape(key); - the body is produced as
<div>{value}</div>, with the tab HTML left unescaped.
- the navigation label is produced as
- It reads the configured template with
Path.open("r"), substitutes$NAV_HTMLand$BODY_HTMLusingstring.Template.substitute(), and returns the resulting document.
The bundled report/_template.html places $NAV_HTML inside #flyte-frame-tabs and $BODY_HTML inside #flyte-frame-container. Its inline JavaScript assigns matching link_index values to navigation children and body children, activates the first pair, and switches both collections when a navigation <li> is clicked. This is why the aggregation preserves the same tab order for navigation and body elements.
Tab names and tab bodies therefore have different handling:
report = Report(name="escaping-example")
report.get_tab("<metrics>").log("<strong>Safe only if the producer controls it</strong>")
rendered = report.get_final_report()
The name is escaped in the navigation markup, but the body fragment is inserted directly. The Report source explicitly notes that escaping the body would display HTML as text and places responsibility for safe HTML on the renderer. Do not pass untrusted text as a body fragment without escaping or otherwise sanitizing it. The Report.name value identifies the object but is not inserted into the bundled HTML.
Customizing the outer template
Pass a pathlib.Path through template_path when the standard document shell is not appropriate:
from pathlib import Path
from flyte.report import Report
report = Report(
name="custom-template",
template_path=Path("report-template.html"),
)
report.get_tab("main").log("<p>Report body</p>")
html = report.get_final_report()
The custom file is read as a string.Template and must contain both $NAV_HTML and $BODY_HTML. substitute() requires those placeholders; a missing or malformed placeholder raises during rendering. The bundled template supplies the complete document structure, inline CSS, and tab-switching JavaScript, and also loads the Lato/Open Sans fonts from a Google Fonts URL.
Task-context lifecycle and persistence
During execution, Flyte installs the report on TaskContext. The remote runner constructs flyte.report.Report(name=action.name) while creating its context, runs the task inside ctx.replace_task_context(tctx), and flushes after successful task execution only when task.report is true:
report = flyte.report.Report(name=action.name)
In the surrounding remote-runner context, the source then calls await flyte.report.flush.aio() when task.report is true. The local and hybrid execution paths also construct a Report(name=action.name) and attach it to their TaskContext. current_report() retrieves the report through internal_ctx().get_report(), which is why code running inside the installed task context can use the module-level helpers and have them mutate the task's report.
Opt in to the task-level reporting path with the report argument on TaskEnvironment.task; its default is False:
@env.task(report=True)
async def build_report():
pass
The task serialization code maps that setting to FlyteIDL metadata as generates_deck=wrappers_pb2.BoolValue(value=task.report). The flag controls the runtime's automatic flush and serialized metadata; report objects are nevertheless created in the local, hybrid, and remote context setup paths regardless of the flag.
flush() only acts inside a task context whose report is not None. It calls report.get_final_report(), derives the destination with io.report_path(internal_ctx().data.task_context.output_path), and uploads UTF-8 encoded bytes through flyte.storage.put_stream(). The upload attributes include both S3-style and GCS-style HTML content types, and the fixed report filename derived by report_path() is report.html.
Operational caveats
- Raw HTML is the contract.
Tab.log()andTab.replace()accept strings and insert them without escaping. They expect partial HTML, not a complete HTML document. Plain text can be interpreted as markup, and untrusted content can result in unsafe output. - Lazy creation can hide a typo.
get_tab("metrcis")creates a new empty tab by default. Usecreate_if_missing=Falsewhen absence should be an error. - Replacement is local to one tab.
flyte.report.replace()replaces onlymain; it does not remove custom tabs. DirectTab.replace()has the same one-tab scope. - Logging does not upload by default.
log()andreplace()default todo_flush=False. Setdo_flush=Trueto flush after that call, or rely on the task runner's conditional post-success flush. - Outside a task context, flushing is a no-op.
flush()returns without work wheninternal_ctx().is_task_context()is false or when no report is attached.current_report()instead returns a freshReport("dummy"); mutations made through that fallback are not attached to a task and are not persisted by a later task flush. - Notebook return type differs from upload expectations. If
ipython_check()reports an IPython environment andIPython.core.display.HTMLis importable,get_final_report()returns an IPythonHTMLobject rather than a string.flush()asserts that the rendered value is astrbefore encoding it, so this combination can fail that assertion instead of uploading. - Keep tab collections aligned. The bundled JavaScript matches navigation and body elements by position-derived
link_indexvalues and activates the first tab. The renderer's paired iteration preserves that alignment; custom templates need to preserve the same relationship if they use the generated fragments.
The repository contains no higher-level report helper call sites, tests, or examples beyond the runtime integration paths. For lifecycle behavior, the authoritative paths are the TaskContext construction in _run.py, the remote execution and conditional flush.aio() call in _internal/runtime/taskrunner.py, and the implementation in report/_report.py.