Skip to main content

Report Generation Architecture

A flyte-sdk report is built in memory during task execution and compiled only when the report is rendered or flushed. Report is the task-scoped accumulator: it owns the named tabs and the HTML template. Tab is the smaller content unit: it stores HTML fragments for one tab and controls whether new content is appended or replaces earlier content.

The lifecycle is:

Task execution

├─ TaskContext.report = Report(name=...)
│ │
│ ├─ main Tab (created automatically)
│ └─ named Tab objects (created by get_tab)
│ │
│ └─ log / replace HTML fragments

├─ Report.get_final_report()
│ ├─ Tab.get_html() for every tab
│ ├─ navigation labels + tab body <div>s
│ └─ report/_template.html

└─ report.flush()
└─ UTF-8 report.html uploaded below TaskContext.output_path

Two layers: the report and its tabs

Report is a dataclass whose required field is name. Its tabs mapping defaults to an empty dictionary, and template_path defaults to the packaged report/_template.html. Immediately after construction, __post_init__ inserts a Tab("main"). Thus, every newly constructed report has a main tab, including one constructed with an otherwise empty tab mapping.

Report.get_tab(name, create_if_missing=True) is the boundary for obtaining a tab. It returns the existing object when name is already present. For an unknown name, the default behavior creates a Tab, stores it in tabs, and returns it. Passing create_if_missing=False changes that behavior to a ValueError:

import flyte.report

report = flyte.report.current_report()
main = report.get_tab("main")
metrics = report.get_tab("metrics")

main.log("<h1>Summary</h1>")
metrics.log("<p>Accuracy: 0.91</p>")

try:
report.get_tab("missing", create_if_missing=False)
except ValueError:
pass

The mapping is a normal Python dictionary. In the supported Python behavior used by flyte-sdk, its insertion order determines the order in which get_final_report() produces navigation entries and body containers. Since main is inserted during initialization, it is normally the first tab; newly created named tabs follow it. The bundled template activates the first navigation item and first body container on page initialization.

A Tab exposes a public name and a content list initialized empty but not accepted as a constructor argument (content is declared with init=False). Its methods operate on fragments, not complete HTML documents. log() appends one fragment to the list, preserving earlier fragments. replace() discards the list and stores only its argument. get_html() joins the current fragments with newline characters:

import flyte.report

tab = flyte.report.current_report().get_tab("events")
tab.log("<p>started</p>")
tab.log("<p>finished</p>")
assert tab.get_html() == "<p>started</p>\n<p>finished</p>"

tab.replace("<p>replaced</p>")
assert tab.get_html() == "<p>replaced</p>"

The append/replace distinction is significant for incremental output. Repeated log() calls create a newline-separated sequence, whereas a later replace() removes every previously logged fragment. The module-level report.log() follows the append path for the main tab; the module-level report.replace() follows the replacement path for that same tab. Both are decorated with syncify, so flyte-sdk exposes synchronous and asynchronous forms, and both accept do_flush=False to optionally flush after updating the tab.

Compilation into the HTML document

Report.get_final_report() first evaluates get_html() for every tab. For each (name, html) pair it builds two pieces:

  • A navigation <li> whose visible label is html.escape(name).
  • A body <div> containing the tab HTML.

The implementation deliberately does not escape the body value. The source comment states that escaping it would cause the HTML to be displayed as text and places responsibility for safe HTML on the renderer. Consequently, strings passed to Tab.log() and Tab.replace() must be trusted or sanitized before they reach the report. Escaping a tab name does not sanitize its body fragments.

The generated pieces are substituted into the file at Report.template_path using string.Template.substitute:

import flyte.report

report = flyte.report.current_report()
report.get_tab("main").log("<h2>Results</h2>")
report.get_tab("details").log("<table><tr><td>ok</td></tr></table>")

rendered = report.get_final_report()

With the default template, the result is a complete HTML document containing the navigation under #flyte-frame-nav, the tab containers under #flyte-frame-container, inline CSS, and JavaScript for selecting tabs. The template has $NAV_HTML and $BODY_HTML placeholders, hides all body containers except the active one, and assigns matching sequential link_index attributes to navigation items and body containers. The Python renderer creates the <li> and <div> elements without those attributes; the template script adds them after the document is loaded. Navigation and body order must therefore remain aligned.

The default template includes inline style and script content, but it also contains a Google Fonts <link> to fonts.googleapis.com. The generated result is one HTML document, while that font reference remains an external resource. A custom template_path can replace the packaged shell, but it must be compatible with string.Template.substitute: it needs valid $NAV_HTML and $BODY_HTML placeholders. Missing placeholders cause substitution to fail rather than being silently retained.

The return type depends on the runtime environment. If ipython_check() reports an IPython environment and IPython.core.display.HTML can be imported, get_final_report() returns an HTML object. Otherwise it returns the assembled HTML string. This is useful for notebook display, but it creates a concrete constraint for persistence: flush() asserts that the result is a str. In an IPython environment where the optional import succeeds, that assertion can fail in the current implementation.

Task-context integration

The report is not stored as a process-wide accumulator. TaskContext has a report: Report field, and Context.get_report() returns that field only when data.task_context exists; outside a task context it returns None. The execution setup populates this field in all of the runtime paths shown by flyte-sdk. Hybrid execution creates Report(name=action.name) in _run.py, and local execution does the same before replacing the current task context. The ordinary runtime runner in _internal/runtime/taskrunner.py also constructs a report named after the action and installs it while it runs the task.

The public helpers resolve this context rather than requiring callers to pass a Report around. current_report() calls internal_ctx().get_report(). If no report is available, it creates and returns a new Report("dummy"). As a result, logging outside a task updates only that temporary dummy report; it is not attached to a context and does not persist. Similarly, flush() returns immediately outside a task context, and returns again if the task context has no report.

The public package re-exports the task-facing API:

import flyte.report

flyte.report.log("<p>generated during the task</p>")
flyte.report.get_tab("plots").log("<img src='plot.png'>")

These calls target the current task's report when a task context is active. flyte.report.log() specifically targets the main tab. Named tabs are obtained with flyte.report.get_tab(), whose default creation behavior is convenient but can hide a spelling mistake; use create_if_missing=False when an unknown tab should be an error.

Enablement and persistence

Report construction and report publication are separate concerns. TaskEnvironment.task() accepts report: bool = False and passes that value into the task template. The task template retains the boolean, and _internal/runtime/task_serde.py serializes it as Flyte task metadata through generates_deck=wrappers_pb2.BoolValue(value=task.report). Reporting is therefore disabled by default at task declaration time unless the decorator is given report=True:

from flyte import TaskEnvironment

env = TaskEnvironment(name="reports")

@env.task(report=True)
def summarize() -> str:
import flyte.report

flyte.report.log("<p>summary is ready</p>")
return "ok"

At the end of a successful ordinary runtime task, _internal/runtime/taskrunner.py checks task.report and calls await flyte.report.flush.aio() only when it is true. The flush operation renders the current Report, derives a destination with io.report_path(internal_ctx().data.task_context.output_path), and uploads UTF-8 encoded bytes through flyte.storage.put_stream. io.report_path() appends the report filename to the task output base, so the persisted object is report.html; the upload supplies both Content-Type and content_type attributes with the value text/html.

A caller can request an earlier publication by using do_flush=True with the module-level logging or replacement helpers. That still depends on an active task context and a report. Because flush() asserts a string result from get_final_report(), notebook rendering and durable upload should be treated as an implementation boundary to verify in the execution environment rather than assumed to have identical return types.