Skip to content

Commit 5c8a121

Browse files
committed
[Node] improve error logging
1 parent 6aa1b18 commit 5c8a121

6 files changed

Lines changed: 171 additions & 7 deletions

File tree

packages/flow/octobot_flow/entities/actions/actions_dependencies.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import typing
2+
import dataclasses
23

34
import octobot_commons.dsl_interpreter
5+
import octobot_commons.logging
46

57
import octobot_flow.entities.actions.action_details as action_details
68
import octobot_flow.enums
@@ -93,7 +95,21 @@ def read_dependency_result(
9395
)
9496
value = dependency_action.result
9597
if dependency.result_path:
96-
value = _navigate_dict_path(value, dependency.result_path)
98+
try:
99+
value = _navigate_dict_path(value, dependency.result_path)
100+
except octobot_flow.errors.ActionDependencyError as err:
101+
dependency_dict = dataclasses.asdict(dependency)
102+
result_keys = (
103+
list(value) if isinstance(value, dict)
104+
else octobot_commons.logging.get_private_minimized_message_if_necessary(value)
105+
)
106+
error_message = (
107+
f"Impossible to resolve dependency {dependency_dict} "
108+
f"result path {dependency.result_path} from action {dependency_action.id} "
109+
f"with action result shape: {result_keys}"
110+
) + f": {err}"
111+
# add as much info as possible to the error message
112+
raise octobot_flow.errors.ActionDependencyError(error_message) from err
97113
return value
98114

99115
def _dependency_values_for_dynamic_dependencies(

packages/flow/tests/entities/actions/test_actions_dependencies.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
#
1414
# You should have received a copy of the GNU Lesser General Public
1515
# License along with this library.
16+
import mock
1617
import pytest
1718

1819
import octobot_commons.constants as commons_constants
20+
import octobot_commons.logging
1921

2022
import octobot_flow.entities.actions.action_details as action_details
2123
import octobot_flow.entities.actions.actions_dependencies as actions_dependencies
@@ -188,6 +190,70 @@ def test_raises_when_dependency_action_errored(self):
188190
with pytest.raises(flow_errors.ActionDependencyError):
189191
resolver.read_dependency_result(dependency)
190192

193+
def test_raises_extended_error_when_result_path_key_missing(self):
194+
dependency_action = action_details.DSLScriptActionDetails(
195+
id="action_source",
196+
dsl_script="exchange_action()",
197+
dependencies=[],
198+
)
199+
dependency_action.complete(result={"nested": {"order_id": "abc-123"}})
200+
dependency = action_details.ActionDependency(
201+
action_id="action_source",
202+
parameter="exchange_order_id",
203+
result_path=["nested", "missing_key"],
204+
)
205+
resolver = actions_dependencies.ActionsDependenciesResolver(_actions_by_id(dependency_action))
206+
207+
with mock.patch.object(
208+
octobot_commons.logging,
209+
"get_private_minimized_message_if_necessary",
210+
) as minimize_message_mock:
211+
with pytest.raises(flow_errors.ActionDependencyError) as error_info:
212+
resolver.read_dependency_result(dependency)
213+
214+
minimize_message_mock.assert_not_called()
215+
error_message = str(error_info.value)
216+
assert "Impossible to resolve dependency" in error_message
217+
assert "action_id': 'action_source'" in error_message
218+
assert "result_path': ['nested', 'missing_key']" in error_message
219+
assert "from action action_source" in error_message
220+
assert "with action result shape: ['nested']" in error_message
221+
assert "missing key: missing_key" in error_message
222+
assert isinstance(error_info.value.__cause__, flow_errors.ActionDependencyError)
223+
224+
def test_raises_extended_error_when_result_path_list_index_out_of_range(self):
225+
dependency_action = action_details.DSLScriptActionDetails(
226+
id="action_source",
227+
dsl_script="exchange_action()",
228+
dependencies=[],
229+
)
230+
dependency_action.complete(result=[{"id": "a"}])
231+
list_result = dependency_action.result
232+
dependency = action_details.ActionDependency(
233+
action_id="action_source",
234+
parameter="exchange_order_id",
235+
result_path=["5"],
236+
)
237+
resolver = actions_dependencies.ActionsDependenciesResolver(_actions_by_id(dependency_action))
238+
239+
with mock.patch.object(
240+
octobot_commons.logging,
241+
"get_private_minimized_message_if_necessary",
242+
return_value="minimized-list-shape",
243+
) as minimize_message_mock:
244+
with pytest.raises(flow_errors.ActionDependencyError) as error_info:
245+
resolver.read_dependency_result(dependency)
246+
247+
minimize_message_mock.assert_called_once_with(list_result)
248+
error_message = str(error_info.value)
249+
assert "Impossible to resolve dependency" in error_message
250+
assert "action_id': 'action_source'" in error_message
251+
assert "result_path': ['5']" in error_message
252+
assert "from action action_source" in error_message
253+
assert "with action result shape: minimized-list-shape" in error_message
254+
assert "list index 5 out of range" in error_message
255+
assert isinstance(error_info.value.__cause__, flow_errors.ActionDependencyError)
256+
191257

192258
class TestActionsDependenciesResolverFilledAllDependencies:
193259
def test_returns_true_when_all_dependencies_completed(self):

packages/node/octobot_node/scheduler/octobot_flow_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
import typing
1717
import dataclasses
1818
import json
19-
import logging
2019

2120
import octobot_commons.dataclasses
21+
import octobot_commons.logging
2222

2323
import octobot_node.errors as errors
2424
import octobot_node.scheduler.workflows_util as workflows_util
@@ -149,7 +149,7 @@ async def run(self) -> None:
149149
self.priority_user_actions
150150
or automation_job.automation_state.automation.actions_dag.get_executable_actions()
151151
)
152-
logging.getLogger(self.__class__.__name__).info(f"Running automation actions: {selected_actions}")
152+
octobot_commons.logging.get_logger(self.__class__.__name__).info(f"Running automation actions: {selected_actions}")
153153
executed_actions = await automation_job.run()
154154
self.after_execution_state = automation_job.automation_state
155155
post_execution_state_dump = automation_job.dump()

packages/node/octobot_node/scheduler/workflows/automation_workflow.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,12 @@ async def execute_iteration(inputs: dict, actions_update: typing.Optional[dict])
199199
has_next_actions_override = True
200200
next_iteration_description_override = parsed_inputs.task.content
201201
next_iteration_description_metadata_override = parsed_inputs.task.content_metadata
202+
except Exception as err:
203+
# log propagated errors to also associate them to the automation's error tracking
204+
AutomationWorkflow.get_logger(parsed_inputs).exception(
205+
err, True, f"Error while running automation job: {err}"
206+
)
207+
raise
202208
if result.processed_actions:
203209
if latest_step := AutomationWorkflow._get_actions_summary(result.processed_actions, minimal=True):
204210
executed_step = latest_step

packages/node/tests/scheduler/test_octobot_flow_client_lib.py renamed to packages/node/tests/scheduler/test_octobot_flow_client.py

File renamed without changes.

packages/node/tests/scheduler/workflows/test_automation_workflow.py

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,27 @@ def _apply_octobot_actions_job_result_template(
128128
target.should_stop = template.should_stop
129129

130130

131+
def _assert_iteration_job_errors_logged(
132+
mock_logger: mock.Mock,
133+
raised_exception: BaseException,
134+
*,
135+
iteration_failure_count: int,
136+
expect_workflow_interrupted_log: bool = False,
137+
) -> None:
138+
expected_call_count = iteration_failure_count + (1 if expect_workflow_interrupted_log else 0)
139+
assert mock_logger.exception.call_count == expected_call_count
140+
for call_index in range(iteration_failure_count):
141+
logged_exception, publish_error, error_message = mock_logger.exception.call_args_list[call_index][0]
142+
assert isinstance(logged_exception, type(raised_exception))
143+
assert str(logged_exception) == str(raised_exception)
144+
assert publish_error is True
145+
assert error_message == f"Error while running automation job: {logged_exception}"
146+
if expect_workflow_interrupted_log:
147+
assert "Interrupted workflow: unexpected critical error: " in str(
148+
mock_logger.exception.call_args_list[-1][0][2]
149+
)
150+
151+
131152
def _octobot_actions_job_mock_class(
132153
*,
133154
run_on_result: typing.Callable[[octobot_flow_client.OctoBotActionsJobResult], typing.Any] | None = None,
@@ -452,7 +473,20 @@ async def test_execute_automation(
452473
assert parsed_output.error == expected_error_status
453474
assert parsed_output.error_message == expected_error_message
454475
assert run_mock.await_count == expected_run_await_count
455-
mock_logger.exception.assert_called_once()
476+
if expected_run_await_count > 1:
477+
_assert_iteration_job_errors_logged(
478+
mock_logger,
479+
raised_exception,
480+
iteration_failure_count=expected_run_await_count,
481+
expect_workflow_interrupted_log=True,
482+
)
483+
else:
484+
_assert_iteration_job_errors_logged(
485+
mock_logger,
486+
raised_exception,
487+
iteration_failure_count=1,
488+
expect_workflow_interrupted_log=True,
489+
)
456490
mock_process.assert_not_called()
457491

458492

@@ -567,6 +601,40 @@ async def test_execute_iteration_missing_trading_signal_sets_no_trading_signal_e
567601
assert parsed_progress_status.error == octobot_flow.enums.ActionErrorStatus.NO_TRADING_SIGNAL.value
568602
assert parsed_progress_status.error_message == error_message
569603

604+
@pytest.mark.asyncio
605+
@required_imports
606+
async def test_execute_iteration_logs_and_reraises_when_octobot_actions_job_fails(
607+
self, import_automation_workflow, task
608+
):
609+
task.content = json.dumps({"params": {"ACTIONS": "trade", "EXCHANGE_FROM": "binance",
610+
"ORDER_SYMBOL": "ETH/BTC", "ORDER_AMOUNT": 1, "ORDER_TYPE": "market",
611+
"ORDER_SIDE": "BUY", "SIMULATED_PORTFOLIO": {"BTC": 1}}})
612+
inputs = params.AutomationWorkflowInputs(task=task, execution_time=0).to_dict(include_default_values=False)
613+
run_error = RuntimeError("automation failed")
614+
mock_octobot_actions_job_class, _ = _octobot_actions_job_mock_class(
615+
run_side_effect=run_error,
616+
)
617+
mock_logger = mock.Mock()
618+
automation_workflow = octobot_node.scheduler.workflows.automation_workflow.AutomationWorkflow
619+
620+
with mock.patch.object(
621+
octobot_flow_client,
622+
"OctoBotActionsJob",
623+
mock_octobot_actions_job_class,
624+
), mock.patch.object(
625+
automation_workflow,
626+
"get_logger",
627+
return_value=mock_logger,
628+
):
629+
with pytest.raises(RuntimeError, match="automation failed"):
630+
await automation_workflow.execute_iteration(inputs, None)
631+
632+
mock_logger.exception.assert_called_once_with(
633+
run_error,
634+
True,
635+
f"Error while running automation job: {run_error}",
636+
)
637+
570638
@pytest.mark.asyncio
571639
@required_imports
572640
async def test_execute_iteration_authentication_error_sets_postponed_iteration(
@@ -1680,7 +1748,11 @@ async def run_with_retries_then_apply_success(*args, **kwargs) -> None:
16801748
assert parsed_output == _parse_automation_workflow_output(wf_status.output)
16811749

16821750
assert run_mock.await_count == max_attempts
1683-
mock_logger.exception.assert_not_called()
1751+
_assert_iteration_job_errors_logged(
1752+
mock_logger,
1753+
RuntimeError("simulated transient failure"),
1754+
iteration_failure_count=max_attempts - 1,
1755+
)
16841756

16851757
@pytest.mark.asyncio
16861758
@required_imports
@@ -1745,8 +1817,12 @@ async def test_execute_automation_execute_iteration_exhausts_retries_when_octobo
17451817
assert wf_status.output == workflow_result
17461818

17471819
assert run_mock.await_count == max_attempts
1748-
mock_logger.exception.assert_called_once()
1749-
assert "Interrupted workflow: unexpected critical error: " in str(mock_logger.exception.call_args[0][2])
1820+
_assert_iteration_job_errors_logged(
1821+
mock_logger,
1822+
RuntimeError("persistent failure"),
1823+
iteration_failure_count=max_attempts,
1824+
expect_workflow_interrupted_log=True,
1825+
)
17501826

17511827
@pytest.mark.asyncio
17521828
@required_imports

0 commit comments

Comments
 (0)