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:
Solution UI before log file content is available#
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.
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.
@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)
@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.
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.
@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.
@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.
@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.
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.
@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.