Skip to content

Commit 1abfd19

Browse files
committed
feat: add training job observability prints for script mode
1 parent 1572b32 commit 1abfd19

2 files changed

Lines changed: 104 additions & 13 deletions

File tree

‎sagemaker-train/src/sagemaker/train/common_utils/trainer_wait.py‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,9 @@ def get_cached_mlflow_url():
419419
raise TimeoutExceededError(resource_type="TrainingJob", status=status)
420420

421421
else:
422-
print(f"\nTrainingJob Name: {training_job.training_job_name}")
422+
print(f"\nTraining job started: {training_job.training_job_name}", flush=True)
423+
print(f"Log group: /aws/sagemaker/TrainingJobs", flush=True)
424+
print(f"Log stream prefix: {training_job.training_job_name}", flush=True)
423425
iteration = 0
424426
while True:
425427
iteration += 1
@@ -432,8 +434,8 @@ def get_cached_mlflow_url():
432434

433435
# Show transitions with checkmarks
434436
if training_job.secondary_status_transitions:
435-
print("\n--------------------------------------\n")
436-
print("Status Transitions:")
437+
print("\n--------------------------------------\n", flush=True)
438+
print("Status Transitions:", flush=True)
437439
for trans in training_job.secondary_status_transitions:
438440
duration, check = _calculate_transition_duration(trans)
439441

@@ -452,33 +454,44 @@ def get_cached_mlflow_url():
452454
if progress_pct is not None:
453455
step_msg += f" - {progress_pct:.1f}%{progress_text.replace(chr(10), ', ')}"
454456

455-
print(step_msg)
456-
print(f"\nStatus: {status} - {secondary_status} (Elapsed: {elapsed:.1f}s)")
457+
print(step_msg, flush=True)
458+
print(f"\nStatus: {status} - {secondary_status} (Elapsed: {elapsed:.1f}s)", flush=True)
457459

458460
if status in ["Completed", "Failed", "Stopped"]:
459461
if status == "Completed":
460462
if mlflow_url:
461-
print(f"\n✓ Training completed! View metrics in MLflow: {mlflow_url}")
463+
print(f"\n✓ Training completed! View metrics in MLflow: {mlflow_url}", flush=True)
462464
try:
463465
steps_per_epoch = training_job.progress_info.total_step_count_per_epoch
464466
loss_metrics_by_epoch = metrics_util._get_loss_metrics_by_epoch(run_name=mlflow_run_name,
465467
steps_per_epoch=steps_per_epoch)
466468
if loss_metrics_by_epoch:
467-
print("\n------------ Loss Metrics by Epoch ------------")
469+
print("\n------------ Loss Metrics by Epoch ------------", flush=True)
468470
for epoch, metrics in list(loss_metrics_by_epoch.items())[:-1]:
469-
print(f"Epoch {epoch}: {metrics}")
470-
print("----------------------------------------------")
471+
print(f"Epoch {epoch}: {metrics}", flush=True)
472+
print("----------------------------------------------", flush=True)
471473
except Exception:
472474
pass
475+
if status == "Failed":
476+
failure_reason = training_job.failure_reason
477+
if failure_reason and not _is_unassigned_attribute(failure_reason):
478+
print(f"\nFailure reason: {failure_reason}", flush=True)
479+
print(f"\nLog group: /aws/sagemaker/TrainingJobs", flush=True)
480+
print(f"Log stream prefix: {training_job.training_job_name}", flush=True)
481+
from sagemaker.train.common_utils.metrics_visualizer import get_cloudwatch_logs_url
482+
cw_url = get_cloudwatch_logs_url(training_job.training_job_arn)
483+
if cw_url:
484+
print(f"CloudWatch Logs: {cw_url}", flush=True)
485+
raise FailedStatusError(resource_type="TrainingJob", status=status, reason=failure_reason)
473486
return
474487

475-
failure_reason = training_job.failure_reason
476-
if status == "Failed" or (failure_reason and not _is_unassigned_attribute(failure_reason)):
477-
raise FailedStatusError(resource_type="TrainingJob", status=status, reason=failure_reason)
478-
479488
if timeout and elapsed >= timeout:
480489
raise TimeoutExceededError(resource_type="TrainingJob", status=status)
481490

491+
failure_reason = training_job.failure_reason
492+
if failure_reason and not _is_unassigned_attribute(failure_reason):
493+
raise FailedStatusError(resource_type="TrainingJob", status=status, reason=failure_reason)
494+
482495

483496
except (FailedStatusError, TimeoutExceededError):
484497
raise
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Tests for training job observability prints in script/terminal mode."""
2+
import time
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
7+
from sagemaker.train.common_utils.trainer_wait import wait, _is_unassigned_attribute
8+
9+
10+
class MockUnassigned:
11+
pass
12+
13+
14+
class MockTrainingJob:
15+
def __init__(self, status="Completed", failure_reason=None):
16+
self.training_job_name = "test-sft-job-2026"
17+
self.training_job_arn = "arn:aws:sagemaker:us-west-2:123456789:training-job/test-sft-job-2026"
18+
self.training_job_status = status
19+
self.secondary_status = "Training"
20+
self.secondary_status_transitions = []
21+
self.progress_info = MockUnassigned()
22+
self.failure_reason = failure_reason
23+
self.mlflow_config = MockUnassigned()
24+
self._call_count = 0
25+
26+
def refresh(self):
27+
self._call_count += 1
28+
29+
30+
class TestTrainingObservabilityAtStart:
31+
"""Test that job info is printed at start in terminal mode."""
32+
33+
@patch("sagemaker.train.common_utils.trainer_wait._is_jupyter_environment", return_value=False)
34+
@patch("sagemaker.train.common_utils.trainer_wait._setup_mlflow_integration", return_value=(None, None, None))
35+
def test_prints_job_info_at_start(self, mock_mlflow, mock_jupyter, capsys):
36+
job = MockTrainingJob(status="Completed")
37+
wait(job, poll=0, timeout=1)
38+
captured = capsys.readouterr()
39+
assert "Training job started: test-sft-job-2026" in captured.out
40+
assert "Log group: /aws/sagemaker/TrainingJobs" in captured.out
41+
assert "Log stream prefix: test-sft-job-2026" in captured.out
42+
43+
44+
class TestTrainingObservabilityOnFailure:
45+
"""Test that debug info is printed on failure."""
46+
47+
@patch("sagemaker.train.common_utils.trainer_wait._is_jupyter_environment", return_value=False)
48+
@patch("sagemaker.train.common_utils.trainer_wait._setup_mlflow_integration", return_value=(None, None, None))
49+
def test_prints_debug_info_on_failure(self, mock_mlflow, mock_jupyter, capsys):
50+
job = MockTrainingJob(status="Failed", failure_reason="OOM error")
51+
with pytest.raises(Exception):
52+
wait(job, poll=0, timeout=1)
53+
captured = capsys.readouterr()
54+
assert "Failure reason: OOM error" in captured.out
55+
assert "Log group: /aws/sagemaker/TrainingJobs" in captured.out
56+
assert "Log stream prefix: test-sft-job-2026" in captured.out
57+
assert "CloudWatch Logs:" in captured.out
58+
59+
@patch("sagemaker.train.common_utils.trainer_wait._is_jupyter_environment", return_value=False)
60+
@patch("sagemaker.train.common_utils.trainer_wait._setup_mlflow_integration", return_value=(None, None, None))
61+
def test_prints_cloudwatch_url_on_failure(self, mock_mlflow, mock_jupyter, capsys):
62+
job = MockTrainingJob(status="Failed", failure_reason="ClientError")
63+
with pytest.raises(Exception):
64+
wait(job, poll=0, timeout=1)
65+
captured = capsys.readouterr()
66+
assert "us-west-2.console.aws.amazon.com/cloudwatch" in captured.out
67+
68+
69+
class TestTrainingObservabilityOnSuccess:
70+
"""Test that MLflow link is printed on success (existing behavior preserved)."""
71+
72+
@patch("sagemaker.train.common_utils.trainer_wait._is_jupyter_environment", return_value=False)
73+
@patch("sagemaker.train.common_utils.trainer_wait._setup_mlflow_integration", return_value=("https://mlflow.example.com", None, None))
74+
def test_prints_mlflow_on_success(self, mock_mlflow, mock_jupyter, capsys):
75+
job = MockTrainingJob(status="Completed")
76+
wait(job, poll=0, timeout=1)
77+
captured = capsys.readouterr()
78+
assert "mlflow.example.com" in captured.out

0 commit comments

Comments
 (0)