Process logs#

Feature highlight#

From a backend perspective, a solution app is about triggering and orchestrating simulation processes (pre, solve, and post). End users may want to access the log files generated by a process and to follow them while the process is still running, rather than waiting for it to complete.

In this example, you learn how to:

  • Write the log file from a long-running transaction method so the UI stays responsive while the process runs.

  • Persist the accumulated log content in a typed step field that references a blob.

  • Push every new log line to the frontend through a backend event.

  • Wire callbacks that append the streamed lines to the log panel and clear the persisted log file.

When you complete this example, you can expect the following output in the solution UI:

../../_images/usage_saf_ex_process_logs_output_1.png

Solution UI before log file content is available#

../../_images/usage_saf_ex_process_logs_output_2.png

Log file content displayed in the solution UI#

Prerequisites#

To display the content of a log file in the solution UI, you need the html.Pre component from the Dash HTML Components library. For more information, see the Plotly Dash html.Pre documentation.

Coding#

To display the logs of a running process, work through the following sequence of sections.

Backend#

Create the solution definition.

Key concept — Typed field

A field is a class attribute with a type annotation and a default value. A field annotated with EntityHandle references a blob: the log content is not stored in the field itself, only the handle that points to it.

Define the step field

Declare a log_file field to hold the handle of the file that accumulates the log content. It defaults to NO_ENTITY until the first line is written.

solution/basic_step.py#
log_file: EntityHandle = NO_ENTITY

Key concept — Long-running transaction method

A transaction method decorated with @long_running is executed in the background, so the frontend stays responsive while the process runs. Setting enable_termination_event=True makes SAF raise an event when the method completes or fails, on a stream named after the method.

Add a transaction method to generate the logs

The generate_process_logs transaction method appends a timestamped line to the log content every second. On each iteration, it persists the updated content with self.storage_scope.store_stream(), assigns the returned handle to log_file, and raises an event carrying only the new line on the generate-process-logs-update stream.

The clear_logs transaction method resets log_file to NO_ENTITY so that the page starts again from an empty file.

solution/basic_step.py#
@transaction(self=StepSpec(download=["log_file"], upload=["log_file"]), enable_termination_event=True)
@long_running
def generate_process_logs(self, wait_time: float) -> None:
    """
    Write the current time to a file every second for a certain amount of time.

    The new lines are appended to the existing ``log_file`` content, and each of them
    is also sent to the frontend through the ``generate-process-logs-update`` event
    stream. The transaction method can be terminated from the UI.

    Parameters
    ----------
    wait_time : float
        Duration of the process, in seconds.
    """
    try:
        log_file = self.storage_scope.get_cached(self.log_file)
        existing_logs = log_file.read_bytes()
    except Exception:
        existing_logs = b""
    start_time = time.time()
    while (time.time() - start_time) < wait_time:
        current_time = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}\n"
        existing_logs += current_time.encode()
        self.log_file = self.storage_scope.store_stream(existing_logs)
        self.transaction.raise_event(message=current_time, stream_name="generate-process-logs-update")
        time.sleep(1)
solution/basic_step.py#
@transaction(self=StepSpec(upload=["log_file"]))
def clear_logs(self) -> None:
    """Reset the ``log_file`` field so that the log page starts from an empty file."""
    self.log_file = NO_ENTITY

Note

Only the new line is sent in the event payload, not the whole log. This keeps the events small and lets the frontend append the line to what is already displayed. For more information about event streams, see Streaming process logs.

Frontend#

Expose the solution definition in the UI.

Define the page layout

In the ui/pages folder, the layout function of the page creates the Start button that launches the transaction, a Clear Logs icon button, and a scrollable card that displays the log content.

The log text is rendered inside a stable html.Pre element identified by log_content. Keeping the log text as a single string on a fixed component, rather than rebuilding the whole container on every update, lets the callbacks update the panel with a simple string concatenation.

ui/pages/basic/process_logs_page.py#
def layout(project: ExamplesSolution) -> html.Div:
    """Build the Process logs page layout.

    The page shows a "Start" button that launches the long running
    ``generate_process_logs`` transaction, and a scrollable card that displays
    the log file content. The log content is streamed live from the backend
    via an event listener and initially populated from the persisted log file
    (if any) when the page is first rendered.
    """
    logs_container = dmc.Card(
        [
            dmc.CardSection(
                dmc.Group(
                    children=[
                        dmc.Text("Logs", fw=500),
                        dmc.Tooltip(
                            dmc.ActionIcon(
                                DashIconify(icon="mdi:delete-sweep", width=24),
                                id="clear-logs-button",
                                color="gray",
                                variant="transparent",
                            ),
                            label="Clear Logs",
                            position="left",
                        ),
                    ],
                    justify="space-between",
                ),
                withBorder=True,
                inheritPadding=True,
                py="xs",
            ),
            dmc.Space(h=20),
            html.Div(
                id="log_container",
                children=html.Pre(
                    id="log_content",
                    children=get_process_logs_text(project),
                    style={"whiteSpace": "pre-wrap", "wordBreak": "break-all", "fontSize": "10px"},
                ),
                style={
                    "height": "600px",
                    "width": "100%",
                    "overflowY": "scroll",
                },
            ),
        ],
        withBorder=True,
        shadow="sm",
        radius="md",
    )

    return html.Div(
        [
            html.H1("Process logs", className="display-3", style={"font-size": "40px", "font-weight": "bold"}),
            dmc.Blockquote(
                "Read the log files for a process and display their content in a solution UI.",
                icon=DashIconify(icon="material-symbols:info", width=30),
                style={"font-size": "18px", "fontStyle": "italic"},
            ),
            dmc.Space(h=20),
            dmc.Button(
                "Start",
                id="start_button",
                variant="filled",
                radius="sm",
                style={
                    "font-size": "16px",
                    "width": "20%",
                    "background-color": "#2790F1",
                },
                leftSection=DashIconify(icon="streamline:startup-solid"),
            ),
            dmc.Space(h=20),
            dmc.Grid(
                [dmc.GridCol(logs_container, span=9)],
                grow=True,
                gutter="xs",
            ),
        ],
        style={"paddingLeft": "20px"},
    )

Key concept — Event listener

An event listener subscribes the frontend to a backend event stream. Each message received on the stream triggers the callbacks that take the listener as an input, so the UI updates itself without polling the backend.

Mount the event listeners

Two listeners are created: one receives every new log line raised on the generate-process-logs-update stream while the transaction is running, and the other receives the termination event raised on the generate-process-logs stream when the transaction completes or fails.

ui/pages/basic/process_logs_page.py#
@callback(
    Output("process-logs-event-listeners-container", "children"),
    Input("url", "pathname"),
)
def mount_event_listeners(project: ExamplesSolution) -> list[dict[str, Any]] | Any:
    """Mount the backend event listeners used by this page.

    Creates two listeners bound to the ``basic_step``: one for the
    ``generate-process-logs-update`` stream that carries incremental log
    lines while the transaction is running, and one for the
    ``generate-process-logs`` stream that carries the transaction's
    termination event. Listeners are (re)mounted whenever the URL changes.
    """
    step = project.steps.basic_step
    return [
        DashClient.create_event_listener(
            step, id="generate-process-logs-update-listener", stream_name="generate-process-logs-update"
        ),
        DashClient.create_event_listener(
            step, id="generate-process-logs-termination-listener", stream_name="generate-process-logs"
        ),
    ]

Key concept — Callback

A callback is a Dash-decorated function that fires in response to a UI event. In a SAF solution, callbacks reach the backend through project.steps.<step_name>, read or write fields, and invoke transaction methods. No manual HTTP calls are needed.

Start the process from the frontend

Write a callback that invokes the generate_process_logs transaction method when the Start button is clicked, and shows a persistent notification while the process is running.

ui/pages/basic/process_logs_page.py#
@callback(
    Output("notification-container", "sendNotifications", allow_duplicate=True),
    Input("start_button", "n_clicks"),
    State("url", "pathname"),
    prevent_initial_call=True,
)
def start_generate_process_logs_transaction(n_clicks: int, project: ExamplesSolution) -> list[dict[str, Any]] | Any:
    """Launch the ``generate_process_logs`` long running transaction.

    Triggered when the user clicks the "Start" button. Starts the backend
    transaction, which writes a timestamped log line every second for
    ``wait_time`` seconds, and shows a persistent loading notification while
    the transaction is in progress.
    """
    notification = no_update

    if ctx.triggered_id == "start_button" and n_clicks:  # pyright: ignore[reportUnknownMemberType]
        logger.info("Launch generate_process_logs transaction")

        step = project.steps.basic_step
        step.generate_process_logs(wait_time=10.0)

        notification = [
            dict(
                title="Info",
                id="generate-process-logs-notification",
                action="show",
                message="Generating logs via long running transaction...",
                autoClose=False,
                loading=True,
                color="blue",
                withCloseButton=False,
            )
        ]

    return notification
Display the streamed log lines

Write a callback that appends each streamed line to the log panel. The event payload is JSON-encoded, so it is decoded with json.loads before being appended; otherwise the raw JSON text, including the escaped \n sequences, would be displayed. When the panel only shows the placeholder message, the new line replaces it instead of being appended.

ui/pages/basic/process_logs_page.py#
@callback(
    Output("log_content", "children"),
    Input("generate-process-logs-update-listener", "message"),
    State("log_content", "children"),
    prevent_initial_call=True,
)
def update_logs_on_backend_events(message: dict[str, Any], current_logs: str) -> str | Any:
    """Append a newly streamed log line to the displayed log text.

    The event payload is JSON-encoded, so it is decoded with ``json.loads``
    before being appended. If no logs are currently displayed (or only the
    placeholder message is shown), the new line replaces it instead of being
    appended, so the placeholder disappears as soon as logs start flowing.
    """
    if (
        ctx.triggered_id == "generate-process-logs-update-listener" and message
    ):  # pyright: ignore[reportUnknownMemberType]
        new_line = json.loads(message["data"])
        if not current_logs or current_logs == NO_LOGS_MESSAGE:
            return new_line
        return current_logs + new_line
    return no_update
Read the persisted log file on the initial render

The get_process_logs_text function reads the content referenced by the log_file handle so that navigating back to the page shows the logs accumulated so far, rather than the placeholder message.

ui/pages/basic/process_logs_page.py#
def get_process_logs_text(project: ExamplesSolution) -> str:
    """Read and return the persisted log file content for the initial render.

    Returns the placeholder message if no log file exists yet, or an error
    message if the log file exists but cannot be read.
    """
    try:
        step = project.steps.basic_step
        log_file = project.storage_scope.get_cached(step.log_file)
    except Exception:
        return NO_LOGS_MESSAGE
    try:
        return log_file.read_text()
    except Exception as e:
        return "Error reading log file: " + str(e)
Clear the logs

Write a callback that invokes the clear_logs transaction method when the Clear Logs button is clicked, and resets the panel to the placeholder message.

ui/pages/basic/process_logs_page.py#
@callback(
    Output("log_content", "children"),
    Input("clear-logs-button", "n_clicks"),
    State("url", "pathname"),
    prevent_initial_call=True,
)
def clear_process_logs(n_clicks: int, project: ExamplesSolution) -> str:
    """Clear the persisted log file and reset the displayed log text.

    Triggered by the "Clear Logs" icon button. Deletes the backend log file
    reference and resets the log panel to the placeholder message.
    """
    if ctx.triggered_id == "clear-logs-button" and n_clicks:  # pyright: ignore[reportUnknownMemberType]
        step = project.steps.basic_step
        step.clear_logs()
        return NO_LOGS_MESSAGE
    return no_update

Now that your implementation is complete, continue to the Testing section.

Testing#

Finally, test your implementation to confirm it works as expected.

Run the solution and compare your results with the results shown in the Feature highlight section.