import asyncio import json import os import sys import urllib from pathlib import Path from typing import List from unittest.mock import AsyncMock, MagicMock import grpc import pytest import requests from click.testing import CliRunner import ray from ray._common.test_utils import wait_for_condition from ray._private.test_utils import ( format_web_url, wait_until_server_available, ) from ray._raylet import ActorID, NodeID, TaskID, WorkerID from ray.core.generated.common_pb2 import Address from ray.core.generated.gcs_pb2 import ( ActorTableData, TaskEvents, TaskLogInfo, TaskStateUpdate, ) from ray.core.generated.gcs_service_pb2 import GetTaskEventsReply from ray.core.generated.reporter_pb2 import ListLogsReply, StreamLogReply from ray.dashboard.modules.log.log_agent import ( LogAgentV1Grpc, _stream_log_in_chunk, find_end_offset_file, find_end_offset_next_n_lines_from_offset, find_offset_of_content_in_file, find_start_offset_last_n_lines_from_offset, ) from ray.dashboard.modules.log.log_manager import LogsManager from ray.dashboard.tests.conftest import * # noqa from ray.util.state import get_log, list_jobs, list_logs, list_nodes, list_workers from ray.util.state.common import GetLogOptions from ray.util.state.exception import RayStateApiException from ray.util.state.state_cli import logs_state_cli_group from ray.util.state.state_manager import StateDataSourceClient def generate_task_event( task_id, node_id, attempt_number, worker_id, stdout_file=None, stderr_file=None, stdout_start=None, stderr_start=None, stdout_end=None, stderr_end=None, ): task_event = TaskEvents( task_id=task_id.binary(), attempt_number=attempt_number, job_id=b"", state_updates=TaskStateUpdate( node_id=node_id.binary(), worker_id=worker_id.binary(), task_log_info=TaskLogInfo( stdout_file=stdout_file, stderr_file=stderr_file, stdout_start=stdout_start, stderr_start=stderr_start, stdout_end=stdout_end, stderr_end=stderr_end, ), ), ) return task_event async def generate_actor_data(id, node_id, worker_id): if worker_id: worker_id = worker_id.binary() message = ActorTableData( actor_id=id.binary(), state=ActorTableData.ActorState.ALIVE, name="abc", pid=1234, class_name="class", address=Address( node_id=node_id.binary(), ip_address="127.0.0.1", port=1234, worker_id=worker_id, ), ) return message # Unit Tests (Log Agent) def _read_file(fp, start, end): """Help func to read a file with offsets""" fp.seek(start, 0) if end == -1: return fp.read() return fp.read(end - start) async def _stream_log(context, fp, start, end): """Help func to stream a log with offsets""" result = bytearray() async for chunk_res in _stream_log_in_chunk( context=context, file=fp, start_offset=start, end_offset=end, keep_alive_interval_sec=-1, ): result += chunk_res.data return result TEST_LINE_TEMPLATE = "{}-test-line" def _write_lines_and_get_offset_at_index( f, num_lines, start_offset=0, trailing_new_line=True ): """ Write multiple lines into a file, and record offsets Args: f: a binary file object that's writable num_lines: Number of lines to write start_offset: The offset to start writing trailing_new_line: True if a '\n' is added at the end of the lines. Return: offsets: A list of offsets of the lines. offset_end: The offset of the end of file. """ f.seek(start_offset, 0) offsets = [] for i in range(num_lines): offsets.append(f.tell()) if i == num_lines - 1 and not trailing_new_line: # Last line no newline line = TEST_LINE_TEMPLATE.format(i) else: line = TEST_LINE_TEMPLATE.format(i) + "\n" f.write(line.encode("utf-8")) f.flush() f.seek(0, 2) offset_end = f.tell() return offsets, offset_end @pytest.mark.parametrize("new_line", [True, False]) @pytest.mark.parametrize("block_size", [4, 16, 256]) def test_find_start_offset_last_n_lines_from_offset(new_line, temp_file, block_size): file = temp_file o, end_file = _write_lines_and_get_offset_at_index( file, num_lines=50, start_offset=0, trailing_new_line=new_line ) # Test the function with different offsets and number of lines to find assert find_start_offset_last_n_lines_from_offset(file, o[3], 1, block_size) == o[2] assert ( find_start_offset_last_n_lines_from_offset(file, o[10], 10, block_size) == o[0] ) # Test end of file last 1 line assert find_start_offset_last_n_lines_from_offset(file, -1, 1, block_size) == o[-1] # Test end of file no line assert ( find_start_offset_last_n_lines_from_offset(file, -1, 0, block_size) == end_file ) # Test no line from middle of file assert ( find_start_offset_last_n_lines_from_offset(file, o[30], 0, block_size) == o[30] ) # Test more lines than file assert ( find_start_offset_last_n_lines_from_offset(file, o[30], 100, block_size) == o[0] ) # Test offsets in the middle of a line assert ( find_start_offset_last_n_lines_from_offset(file, o[2] + 1, 1, block_size) == o[2] ) assert ( find_start_offset_last_n_lines_from_offset(file, o[2] - 1, 1, block_size) == o[1] ) def test_find_end_offset_next_n_lines_from_offset(temp_file): file = temp_file o, end_file = _write_lines_and_get_offset_at_index( file, num_lines=10, start_offset=0 ) # Test the function with different offsets and number of lines to find assert find_end_offset_next_n_lines_from_offset(file, o[3], 1) == o[4] assert find_end_offset_next_n_lines_from_offset(file, o[3], 2) == o[5] assert find_end_offset_next_n_lines_from_offset(file, 0, 1) == o[1] # Test end of file assert find_end_offset_next_n_lines_from_offset(file, o[3], 999) == end_file # Test offset diff assert find_end_offset_next_n_lines_from_offset(file, 1, 1) == o[1] assert find_end_offset_next_n_lines_from_offset(file, o[1] - 1, 1) == o[1] def test_find_offset_of_content_in_file(temp_file): file = temp_file o, end_file = _write_lines_and_get_offset_at_index(file, num_lines=10) assert ( find_offset_of_content_in_file( file, TEST_LINE_TEMPLATE.format(0).encode("utf-8") ) == o[0] ) assert ( find_offset_of_content_in_file( file, TEST_LINE_TEMPLATE.format(3).encode("utf-8"), o[1] + 1 ) == o[3] ) assert ( find_offset_of_content_in_file( file, TEST_LINE_TEMPLATE.format(4).encode("utf-8"), o[1] - 1 ) == o[4] ) # Not found assert ( find_offset_of_content_in_file( file, TEST_LINE_TEMPLATE.format(1000).encode("utf-8"), o[1] - 1 ) == -1 ) @pytest.mark.asyncio @pytest.mark.parametrize("random_ascii_file", [1 << 20], indirect=True) @pytest.mark.parametrize( "start_offset,end_offset", [ (0, 1 << 20), (1 << 20, 1 << 20), (0, 0), (0, 1), (1 << 16, 1 << 20), (1024, 2042), ], ) async def test_stream_log_in_chunk(random_ascii_file, start_offset, end_offset): """Test streaming of a file from different offsets""" test_file = random_ascii_file context = MagicMock(grpc.aio.ServicerContext) context.done.return_value = False expected_file_content = _read_file(test_file, start_offset, end_offset) actual_log_content = await _stream_log(context, test_file, start_offset, end_offset) assert ( expected_file_content == actual_log_content ), "Non-matching content from log streamed" @pytest.mark.asyncio @pytest.mark.parametrize( "lines_to_tail,total_lines", [(0, 100), (100, 100), (10, 100), (1, 100), (99, 100)], ) @pytest.mark.parametrize("trailing_new_line", [True, False]) async def test_log_tails(lines_to_tail, total_lines, trailing_new_line, temp_file): """Test tailing a file works""" _write_lines_and_get_offset_at_index( temp_file, total_lines, trailing_new_line=trailing_new_line, ) test_file = temp_file context = MagicMock(grpc.aio.ServicerContext) context.done.return_value = False start_offset = find_start_offset_last_n_lines_from_offset( test_file, offset=-1, n=lines_to_tail ) actual_data = await _stream_log(context, test_file, start_offset, -1) expected_data = _read_file(test_file, start_offset, -1) assert actual_data == expected_data, "Non-matching data from stream log" all_lines = actual_data.decode("utf-8") assert all_lines.count("\n") == ( lines_to_tail if trailing_new_line or lines_to_tail == 0 else lines_to_tail - 1 ), "Non-matching number of lines tailed" @pytest.mark.asyncio @pytest.mark.parametrize( "lines_to_tail,total_lines", [(0, 5), (5, 5), (2, 5), (1, 5), (4, 5)], ) async def test_log_tails_with_appends(lines_to_tail, total_lines, temp_file): """Test tailing a log file that grows at the same time""" _write_lines_and_get_offset_at_index(temp_file, total_lines) test_file = temp_file context = MagicMock(grpc.aio.ServicerContext) context.done.return_value = False start_offset = find_start_offset_last_n_lines_from_offset( test_file, offset=-1, n=lines_to_tail ) actual_data = await _stream_log(context, test_file, start_offset, -1) end_offset = find_end_offset_file(test_file) expected_data = _read_file(test_file, start_offset, end_offset) assert actual_data == expected_data, "Non-matching data from stream log" all_lines = actual_data.decode("utf-8") assert all_lines.count("\n") == lines_to_tail, "Non-matching number of lines tailed" # Modify the file with append here num_new_lines = 2 _write_lines_and_get_offset_at_index( temp_file, num_new_lines, start_offset=end_offset ) # Tail again should read the new lines written start_offset = find_start_offset_last_n_lines_from_offset( test_file, offset=-1, n=lines_to_tail + num_new_lines ) expected_data = _read_file(test_file, start_offset, -1) actual_data = await _stream_log(context, test_file, start_offset, -1) assert ( actual_data == expected_data ), "Non-matching data from stream log after append" all_lines = actual_data.decode("utf-8") assert ( all_lines.count("\n") == lines_to_tail + num_new_lines ), "Non-matching number of lines tailed after append" def test_log_agent_resolve_filename(temp_dir): """ Test that LogAgentV1Grpc.resolve_filename(root, filename) works: 1. Not possible to resolve a file that doesn't exist. 2. Not able to resolve files outside of the temp dir root. - with a absolute path. - with a relative path recursive up. 3. Permits a file in a directory that's symlinked into the root dir. """ root = Path(temp_dir) # Create a file in the temp dir. file = root / "valid_file" file.touch() subdir = root / "subdir" subdir.mkdir() # Create a directory in the root that contains a valid file and # is symlinked to by a path in the subdir. symlinked_dir = root / "symlinked" symlinked_dir.mkdir() symlinked_file = symlinked_dir / "valid_file" symlinked_file.touch() symlinked_path_in_subdir = subdir / "symlink_to_outside_dir" symlinked_path_in_subdir.symlink_to(symlinked_dir) # Test file doesn't exist with pytest.raises(FileNotFoundError): LogAgentV1Grpc._resolve_filename(root, "non-exist-file") # Test absolute path outside of root is not allowed with pytest.raises(FileNotFoundError): LogAgentV1Grpc._resolve_filename(subdir, root.resolve() / "valid_file") # Test relative path recursive up is not allowed with pytest.raises(FileNotFoundError): LogAgentV1Grpc._resolve_filename(subdir, "../valid_file") # Test relative path a valid file is allowed assert ( LogAgentV1Grpc._resolve_filename(root, "valid_file") == (root / "valid_file").resolve() ) # Test relative path to a valid file following a symlink is allowed assert ( LogAgentV1Grpc._resolve_filename(subdir, "symlink_to_outside_dir/valid_file") == (root / "symlinked" / "valid_file").resolve() ) # Unit Tests (LogsManager) @pytest.fixture def logs_manager(): client = AsyncMock(StateDataSourceClient) manager = LogsManager(client) yield manager def generate_list_logs(log_files: List[str]): return ListLogsReply(log_files=log_files) def generate_logs_stream_chunk(index: int): return f"{str(index)*10}" async def generate_logs_stream(num_chunks: int): for i in range(num_chunks): data = generate_logs_stream_chunk(index=i) yield StreamLogReply(data=data.encode()) @pytest.mark.asyncio async def test_logs_manager_list_logs(logs_manager): logs_client = logs_manager.data_source_client async def my_list_logs(node_id, glob_filter, timeout): if node_id != "2": raise ValueError("Agent for node id: 3 doesn't exist.") return generate_list_logs(["gcs_server.out"]) logs_client.list_logs = AsyncMock() logs_client.list_logs.side_effect = my_list_logs with pytest.raises(ValueError): result = await logs_manager.list_logs( node_id="3", timeout=30, glob_filter="*gcs*" ) result = await logs_manager.list_logs(node_id="2", timeout=30, glob_filter="*gcs*") assert len(result) == 1 assert result["gcs_server"] == ["gcs_server.out"] assert result["raylet"] == [] with pytest.raises(ValueError): result = await logs_manager.list_logs( node_id="1", timeout=30, glob_filter="*gcs*" ) @pytest.mark.asyncio async def test_logs_manager_resolve_file(logs_manager): node_id = NodeID(b"1" * 28) """ Test filename is given. """ logs_client = logs_manager.data_source_client expected_filename = "filename" res = await logs_manager.resolve_filename( node_id=node_id.hex(), log_filename=expected_filename, actor_id=None, task_id=None, pid=None, get_actor_fn=None, timeout=10, ) log_file_name, n = res.filename, res.node_id assert log_file_name == expected_filename assert n == node_id.hex() """ Test actor id is given. """ # Actor doesn't exist. with pytest.raises(ValueError): actor_id = ActorID(b"2" * 16) async def get_actor_fn(id): if id == actor_id: return None assert False, "Not reachable." await logs_manager.resolve_filename( node_id=node_id.hex(), log_filename=None, actor_id=actor_id.hex(), task_id=None, pid=None, get_actor_fn=get_actor_fn, timeout=10, ) # Actor exists, but it is not scheduled yet. actor_id = ActorID(b"2" * 16) with pytest.raises(ValueError): await logs_manager.resolve_filename( node_id=node_id.hex(), log_filename=None, actor_id=actor_id.hex(), task_id=None, pid=None, get_actor_fn=lambda _: generate_actor_data(actor_id, node_id, None), timeout=10, ) # Actor exists. actor_id = ActorID(b"2" * 16) worker_id = WorkerID(b"3" * 28) logs_manager.list_logs = AsyncMock() logs_manager.list_logs.return_value = { "worker_out": [f"worker-{worker_id.hex()}-123-123.out"], "worker_err": [], } res = await logs_manager.resolve_filename( node_id=node_id.hex(), log_filename=None, actor_id=actor_id.hex(), task_id=None, pid=None, get_actor_fn=lambda _: generate_actor_data(actor_id, node_id, worker_id), timeout=10, ) log_file_name, n = res.filename, res.node_id logs_manager.list_logs.assert_awaited_with( node_id.hex(), 10, glob_filter=f"*{worker_id.hex()}*out" ) assert log_file_name == f"worker-{worker_id.hex()}-123-123.out" assert n == node_id.hex() """ Test task id is given. """ task_id = TaskID(b"2" * 24) logs_client = logs_manager.data_source_client logs_client.get_all_task_info = AsyncMock() logs_client.get_all_task_info.return_value = GetTaskEventsReply( events_by_task=[ generate_task_event( task_id, node_id, attempt_number=1, worker_id=worker_id, stdout_file=f"worker-{worker_id.hex()}-123-123.out", ) ] ) # Expect resolved file. res = await logs_manager.resolve_filename(task_id=task_id, attempt_number=1) filename, n = res.filename, res.node_id # Default out file. See generate_task_event() for filename assert filename == f"worker-{worker_id.hex()}-123-123.out" assert n == node_id.hex() # Wrong task attempt with pytest.raises(FileNotFoundError): await logs_manager.resolve_filename(task_id=task_id, attempt_number=0) # No task found logs_client.get_all_task_info.return_value = GetTaskEventsReply(events_by_task=[]) with pytest.raises(FileNotFoundError): await logs_manager.resolve_filename(task_id=TaskID(b"1" * 24), attempt_number=1) """ Test pid is given. """ # Pid doesn't exist. with pytest.raises(FileNotFoundError): pid = 456 logs_manager.list_logs = AsyncMock() # Provide the wrong pid. logs_manager.list_logs.return_value = { "worker_out": ["worker-123-123-123.out"], "worker_err": [], } await logs_manager.resolve_filename( node_id=node_id.hex(), log_filename=None, actor_id=None, task_id=None, pid=pid, get_actor_fn=lambda _: generate_actor_data(actor_id, node_id, worker_id), timeout=10, ) # Pid exists. pid = 123 logs_manager.list_logs = AsyncMock() # Provide the wrong pid. logs_manager.list_logs.return_value = { "worker_out": [f"worker-123-123-{pid}.out"], "worker_err": [], } res = await logs_manager.resolve_filename( node_id=node_id.hex(), log_filename=None, actor_id=None, task_id=None, pid=pid, get_actor_fn=lambda _: generate_actor_data(actor_id, node_id, worker_id), timeout=10, ) log_file_name, n = res.filename, res.node_id logs_manager.list_logs.assert_awaited_with( node_id.hex(), 10, glob_filter=f"*{pid}*out" ) assert log_file_name == f"worker-123-123-{pid}.out" """ Test nothing is given. """ with pytest.raises(FileNotFoundError): await logs_manager.resolve_filename( node_id=node_id.hex(), log_filename=None, actor_id=None, task_id=None, pid=None, get_actor_fn=lambda _: generate_actor_data(actor_id, node_id, worker_id), timeout=10, ) """ Test suffix is specified """ pid = 123 logs_manager.list_logs = AsyncMock() logs_manager.list_logs.return_value = { "worker_out": [f"worker-123-123-{pid}.out"], "worker_err": [], } res = await logs_manager.resolve_filename( node_id=node_id.hex(), log_filename=None, actor_id=None, task_id=None, pid=pid, get_actor_fn=lambda _: generate_actor_data(actor_id, node_id, worker_id), timeout=10, ) log_file_name, n = res.filename, res.node_id logs_manager.list_logs.assert_awaited_with( node_id.hex(), 10, glob_filter=f"*{pid}*out" ) assert log_file_name == f"worker-123-123-{pid}.out" logs_manager.list_logs.return_value = { "worker_out": [], "worker_err": [f"worker-123-123-{pid}.err"], } res = await logs_manager.resolve_filename( node_id=node_id.hex(), log_filename=None, actor_id=None, task_id=None, pid=pid, get_actor_fn=lambda _: generate_actor_data(actor_id, node_id, worker_id), timeout=10, suffix="err", ) log_file_name, n = res.filename, res.node_id logs_manager.list_logs.assert_awaited_with( node_id.hex(), 10, glob_filter=f"*{pid}*err" ) assert log_file_name == f"worker-123-123-{pid}.err" async def get_actor_info_raises(actor_id): raise ValueError("should not be called") @pytest.mark.asyncio async def test_logs_manager_stream_log(logs_manager): NUM_LOG_CHUNKS = 10 logs_client = logs_manager.data_source_client logs_client.stream_log.return_value = generate_logs_stream(NUM_LOG_CHUNKS) # Test file_name, media_type="file", node_id options = GetLogOptions( timeout=30, media_type="file", lines=10, node_id="1", filename="raylet.out" ) i = 0 async for chunk in logs_manager.stream_logs( options, get_actor_fn=get_actor_info_raises ): assert chunk.decode("utf-8") == generate_logs_stream_chunk(index=i) i += 1 assert i == NUM_LOG_CHUNKS logs_client.stream_log.assert_awaited_with( node_id="1", log_file_name="raylet.out", keep_alive=False, lines=10, interval=None, timeout=30, start_offset=None, end_offset=None, ) # Test pid, media_type = "stream", node_ip async def returns_1(node_ip): return "1" logs_client.ip_to_node_id = returns_1 logs_client.list_logs.side_effect = [ generate_list_logs( ["worker-0-0-10.out", "worker-0-0-11.out", "worker-0-0-10.err"] ), ] options = GetLogOptions( timeout=30, media_type="stream", lines=10, interval=0.5, node_id="1", pid="10" ) logs_client.stream_log.return_value = generate_logs_stream(NUM_LOG_CHUNKS) i = 0 async for chunk in logs_manager.stream_logs( options, get_actor_fn=get_actor_info_raises ): assert chunk.decode("utf-8") == generate_logs_stream_chunk(index=i) i += 1 assert i == NUM_LOG_CHUNKS logs_client.stream_log.assert_awaited_with( node_id="1", log_file_name="worker-0-0-10.out", keep_alive=True, lines=10, interval=0.5, timeout=None, start_offset=None, end_offset=None, ) # Currently cannot test actor_id with AsyncMock. # It will be tested by the integration test. @pytest.mark.asyncio async def test_logs_manager_keepalive_no_timeout(logs_manager): """Test when --follow is specified, there's no timeout. Related: https://github.com/ray-project/ray/issues/25721 """ NUM_LOG_CHUNKS = 10 logs_client = logs_manager.data_source_client logs_client.stream_log.return_value = generate_logs_stream(NUM_LOG_CHUNKS) # Test file_name, media_type="file", node_id options = GetLogOptions( timeout=30, media_type="stream", lines=10, node_id="1", filename="raylet.out" ) async for chunk in logs_manager.stream_logs( options, get_actor_fn=get_actor_info_raises ): pass # Make sure timeout == None when media_type == stream. This is to avoid # closing the connection due to DEADLINE_EXCEEDED when --follow is specified. logs_client.stream_log.assert_awaited_with( node_id="1", log_file_name="raylet.out", keep_alive=True, lines=10, interval=None, timeout=None, start_offset=None, end_offset=None, ) # Integration tests def test_logs_list(ray_start_with_dashboard): assert ( wait_until_server_available(ray_start_with_dashboard.address_info["webui_url"]) is True ) webui_url = ray_start_with_dashboard.address_info["webui_url"] webui_url = format_web_url(webui_url) node_id = list_nodes()[0]["node_id"] def verify(): response = requests.get(webui_url + f"/api/v0/logs?node_id={node_id}") response.raise_for_status() result = json.loads(response.text) assert result["result"] logs = result["data"]["result"] # Test worker logs outs = logs["worker_out"] errs = logs["worker_err"] core_worker_logs = logs["core_worker"] assert len(outs) == len(errs) == len(core_worker_logs) assert len(outs) > 0 # Test gcs / raylet / dashboard for file in ["gcs_server.out", "gcs_server.err"]: assert file in logs["gcs_server"] for file in ["raylet.out", "raylet.err"]: assert file in logs["raylet"] for file in ["dashboard.log"]: assert file in logs["dashboard"] for file in ["dashboard_agent.log"]: assert file in logs["agent"] return True wait_for_condition(verify) def verify_filter(): # Test that logs/list can be filtered response = requests.get( webui_url + f"/api/v0/logs?node_id={node_id}&glob=*gcs*" ) response.raise_for_status() result = json.loads(response.text) assert result["result"] logs = result["data"]["result"] assert "gcs_server" in logs assert len(logs) == 1 assert "gcs_server.out" in logs["gcs_server"] assert "gcs_server.err" in logs["gcs_server"] return True wait_for_condition(verify_filter) def verify_worker_logs(): response = requests.get( webui_url + f"/api/v0/logs?node_id={node_id}&glob=*worker*" ) response.raise_for_status() result = json.loads(response.text) assert result["result"] logs = result["data"]["result"] worker_log_categories = [ "core_worker", "worker_out", "worker_err", ] assert all([cat in logs for cat in worker_log_categories]) num_workers = len( list(filter(lambda w: w["worker_type"] == "WORKER", list_workers())) ) assert ( len(logs["worker_out"]) == len(logs["worker_err"]) == len(logs["worker_out"]) ) assert num_workers == len(logs["worker_out"]) return True wait_for_condition(verify_worker_logs) @pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.") def test_logs_stream_and_tail(ray_start_with_dashboard): assert ( wait_until_server_available(ray_start_with_dashboard.address_info["webui_url"]) is True ) webui_url = ray_start_with_dashboard.address_info["webui_url"] webui_url = format_web_url(webui_url) node_id = list_nodes()[0]["node_id"] def verify_basic(): stream_response = requests.get( webui_url + f"/api/v0/logs/file?node_id={node_id}&filename=gcs_server.out&lines=5", stream=True, ) if stream_response.status_code != 200: raise ValueError(stream_response.content.decode("utf-8")) lines = [] for line in stream_response.iter_lines(): lines.append(line.decode("utf-8")) assert len(lines) == 5 or len(lines) == 6 return True wait_for_condition(verify_basic) @ray.remote class Actor: def write_log(self, strings): for s in strings: print(s) def getpid(self): return os.getpid() test_log_text = "test_log_text_日志_{}" actor = Actor.remote() ray.get(actor.write_log.remote([test_log_text.format("XXXXXX")])) # Test stream and fetching by actor id stream_response = requests.get( webui_url + "/api/v0/logs/stream?&lines=-1" + f"&actor_id={actor._ray_actor_id.hex()}", stream=True, ) if stream_response.status_code != 200: raise ValueError(stream_response.content.decode("utf-8")) stream_iterator = stream_response.iter_content(chunk_size=None) actual_output = next(stream_iterator).decode("utf-8") assert "actor_name:Actor\n" in actual_output assert test_log_text.format("XXXXXX") in actual_output streamed_string = "" for i in range(5): strings = [] for j in range(3): strings.append(test_log_text.format(f"{3*i + j:06d}")) ray.get(actor.write_log.remote(strings)) string = "" for s in strings: string += s + "\n" streamed_string += string # NOTE: Prefix 1 indicates the stream has succeeded. assert string in next(stream_iterator).decode("utf-8") del stream_response # Test tailing log by actor id LINES = 150 file_response = requests.get( webui_url + f"/api/v0/logs/file?&lines={LINES}" + "&actor_id=" + actor._ray_actor_id.hex(), ).content.decode("utf-8") # NOTE: Prefix 1 indicates the stream has succeeded. for line in streamed_string.split("\n")[-(LINES + 1) :]: assert line in file_response # Test query by pid & node_ip instead of actor id. node_ip = list(ray.nodes())[0]["NodeManagerAddress"] pid = ray.get(actor.getpid.remote()) file_response = requests.get( webui_url + f"/api/v0/logs/file?node_ip={node_ip}&lines={LINES}" + f"&pid={pid}", ).content.decode("utf-8") # NOTE: Prefix 1 indicates the stream has succeeded. for line in streamed_string.split("\n")[-(LINES + 1) :]: assert line in file_response def test_log_download_filename(ray_start_with_dashboard): """Test that the download filename can be specified when downloading a log.""" assert ( wait_until_server_available(ray_start_with_dashboard.address_info["webui_url"]) is True ) webui_url = ray_start_with_dashboard.address_info["webui_url"] webui_url = format_web_url(webui_url) node_id = list_nodes()[0]["node_id"] download_filename = "dummy.out" def verify(): stream_response = requests.get( webui_url + ( f"/api/v0/logs/file?node_id={node_id}&filename=gcs_server.out" f"&lines=5&download_filename={download_filename}" ), stream=True, ) if stream_response.status_code != 200: raise ValueError(stream_response.content.decode("utf-8")) assert ( stream_response.headers["Content-Disposition"] == f'attachment; filename="{download_filename}"' ) return True # Node ID may not be found immediately, so may need to retry the check a few times. wait_for_condition(verify) def test_log_list(ray_start_cluster): cluster = ray_start_cluster num_nodes = 5 for _ in range(num_nodes): cluster.add_node(num_cpus=0) ray.init(address=cluster.address) def verify(): for node in list_nodes(): # When glob filter is not provided, it should provide all logs logs = list_logs(node_id=node["node_id"]) assert "raylet" in logs assert "gcs_server" in logs assert "dashboard" in logs assert "agent" in logs assert "internal" in logs assert "driver" in logs assert "autoscaler" in logs # Test glob works. logs = list_logs(node_id=node["node_id"], glob_filter="raylet*") assert len(logs) == 1 return True wait_for_condition(verify) node_id = "XXXX" with pytest.raises(requests.HTTPError) as e: list_logs(node_id=node_id) assert e.value.response.status_code == 500 @pytest.mark.skipif( sys.platform == "win32", reason="Job submission is failing on windows." ) def test_log_job(ray_start_with_dashboard): assert wait_until_server_available(ray_start_with_dashboard["webui_url"]) is True webui_url = ray_start_with_dashboard["webui_url"] webui_url = format_web_url(webui_url) node_id = list_nodes()[0]["node_id"] # Submit a job from ray.job_submission import JobSubmissionClient JOB_LOG = "test-job-log" client = JobSubmissionClient(webui_url) entrypoint = f"python -c \"print('{JOB_LOG}')\"" job_id = client.submit_job(entrypoint=entrypoint) def job_done(): jobs = list_jobs(filters=[("submission_id", "=", job_id)]) assert len(jobs) == 1 assert jobs[0].status == "SUCCEEDED" return True wait_for_condition(job_done) def verify(): logs = "".join(get_log(submission_id=job_id, node_id=node_id)) assert JOB_LOG + "\n" in logs return True wait_for_condition(verify) def test_log_get_invalid_filenames(ray_start_with_dashboard, temp_file): assert ( wait_until_server_available(ray_start_with_dashboard.address_info["webui_url"]) is True ) webui_url = ray_start_with_dashboard.address_info["webui_url"] webui_url = format_web_url(webui_url) node_id = list_nodes()[0]["node_id"] # log_dir = ray._private.worker.global_worker.node.get_logs_dir_path() def verify(): # Kind of hack that we know the file node_ip_address.json exists in ray. with pytest.raises(RayStateApiException) as e: logs = "".join(get_log(node_id=node_id, filename="../node_ip_address.json")) print(logs) assert "does not start with " in str(e.value) return True wait_for_condition(verify) # Verify that reading file outside of the log directory is not allowed # with absolute path. def verify(): # Kind of hack that we know the file node_ip_address.json exists in ray. temp_file_abs_path = str(Path(temp_file.name).resolve()) with pytest.raises(RayStateApiException) as e: logs = "".join(get_log(node_id=node_id, filename=temp_file_abs_path)) print(logs) assert "does not start with " in str(e.value) return True wait_for_condition(verify) def test_log_get_subdir(ray_start_with_dashboard): assert ( wait_until_server_available(ray_start_with_dashboard.address_info["webui_url"]) is True ) webui_url = ray_start_with_dashboard.address_info["webui_url"] webui_url = format_web_url(webui_url) node_id = list_nodes()[0]["node_id"] log_dir = ray._private.worker.global_worker.node.get_logs_dir_path() subdir = "test_subdir" file = "test_#file.log" path = Path(log_dir) / subdir / file path.parent.mkdir(parents=True, exist_ok=True) path.write_text("test log") # HTTP endpoint def verify(): # Direct logs stream response = requests.get( webui_url + f"/api/v0/logs/file?node_id={node_id}" + f"&filename={urllib.parse.quote('test_subdir/test_#file.log')}" ) assert response.status_code == 200, response.reason assert "test log" in response.text return True wait_for_condition(verify) # get log SDK def verify(): logs = "".join(get_log(node_id=node_id, filename="test_subdir/test_#file.log")) assert "test log" in logs return True wait_for_condition(verify) def test_log_get(ray_start_cluster): cluster = ray_start_cluster cluster.add_node(num_cpus=0) ray.init(address=cluster.address) head_node = list_nodes()[0] cluster.add_node(num_cpus=1) @ray.remote(num_cpus=1) class Actor: def print(self, i): for _ in range(i): print("1") def getpid(self): import os return os.getpid() """ Test filename match """ def verify(): # By default, node id should be configured to the head node. for log in get_log( node_id=head_node["node_id"], filename="raylet.out", tail=10 ): # + 1 since the last line is just empty. assert len(log.split("\n")) == 11 return True wait_for_condition(verify) """ Test worker pid / IP match """ a = Actor.remote() pid = ray.get(a.getpid.remote()) ray.get(a.print.remote(20)) def verify(): # By default, node id should be configured to the head node. for log in get_log(node_ip=head_node["node_ip"], pid=pid, tail=10): # + 1 since the last line is just empty. assert len(log.split("\n")) == 11 return True wait_for_condition(verify) """ Test actor logs. """ actor_id = a._actor_id.hex() def verify(): # By default, node id should be configured to the head node. for log in get_log(actor_id=actor_id, tail=10): # + 1 since the last line is just empty. assert len(log.split("\n")) == 11 return True wait_for_condition(verify) del a """ Test log suffix selection for worker/actor """ ACTOR_LOG_LINE = "{dest}:test actor log" @ray.remote class Actor: def __init__(self): import sys print(ACTOR_LOG_LINE.format(dest="out")) print(ACTOR_LOG_LINE.format(dest="err"), file=sys.stderr) actor = Actor.remote() actor_id = actor._actor_id.hex() WORKER_LOG_LINE = "{dest}:test worker log" @ray.remote def worker_func(): import os import sys print(WORKER_LOG_LINE.format(dest="out")) print(WORKER_LOG_LINE.format(dest="err"), file=sys.stderr) return os.getpid() pid = ray.get(worker_func.remote()) def verify(): # Test actors lines = get_log(actor_id=actor_id, suffix="err") assert ACTOR_LOG_LINE.format(dest="err") in "".join(lines) lines = get_log(actor_id=actor_id, suffix="out") assert ACTOR_LOG_LINE.format(dest="out") in "".join(lines) # Default to out lines = get_log(actor_id=actor_id) assert ACTOR_LOG_LINE.format(dest="out") in "".join(lines) # Test workers lines = get_log(node_ip=head_node["node_ip"], pid=pid, suffix="err") assert WORKER_LOG_LINE.format(dest="err") in "".join(lines) lines = get_log(node_ip=head_node["node_ip"], pid=pid, suffix="out") assert WORKER_LOG_LINE.format(dest="out") in "".join(lines) lines = get_log(node_ip=head_node["node_ip"], pid=pid) assert WORKER_LOG_LINE.format(dest="out") in "".join(lines) return True wait_for_condition(verify) def verify(): runner = CliRunner() result = runner.invoke( logs_state_cli_group, ["actor", "--id", actor_id], ) assert result.exit_code == 0, result.exception assert ACTOR_LOG_LINE.format(dest="out") in result.output result = runner.invoke( logs_state_cli_group, [ "actor", "--id", actor_id, "--err", ], ) assert result.exit_code == 0, result.exception assert ACTOR_LOG_LINE.format(dest="err") in result.output return True wait_for_condition(verify) """ Test ANSI escape codes are filtered in logs """ NO_COLOR_MESSAGE = "test message: no color" UNCOLORED_MESSAGE = "test message: red green blue" COLORED_MESSAGE = ( UNCOLORED_MESSAGE.replace("red", "\033[0;31mred\033[0m") .replace("green", "\033[0;32mgreen\033[0m") .replace("blue", "\033[0;34mblue\033[0m") ) @ray.remote class Actor: def __init__(self): print(NO_COLOR_MESSAGE) print(COLORED_MESSAGE) actor = Actor.remote() actor_id = actor._actor_id.hex() def verify(): lines_wo_ansi = get_log(actor_id=actor_id, suffix="out", filter_ansi_code=True) joined_lines_wo_ansi = "".join(lines_wo_ansi) assert NO_COLOR_MESSAGE in joined_lines_wo_ansi assert UNCOLORED_MESSAGE in joined_lines_wo_ansi assert COLORED_MESSAGE not in joined_lines_wo_ansi lines_w_ansi = get_log(actor_id=actor_id, suffix="out") joined_lines_w_ansi = "".join(lines_w_ansi) assert NO_COLOR_MESSAGE in joined_lines_w_ansi assert UNCOLORED_MESSAGE not in joined_lines_w_ansi assert COLORED_MESSAGE in joined_lines_w_ansi # If a random value is set as a path query string for `filter_ansi_code`, # it should be treated as default behavior lines_rand_val = get_log( actor_id=actor_id, suffix="out", filter_ansi_code="random value" ) joined_lines_rand_val = "".join(lines_rand_val) lines_default = get_log(actor_id=actor_id, suffix="out", filter_ansi_code=None) joined_lines_default = "".join(lines_default) assert joined_lines_rand_val == joined_lines_default, joined_lines_default return True wait_for_condition(verify) ############################## # Test binary files and encodings. ############################## # Write a binary file to ray log directory. log_dir = ray._private.worker.global_worker.node.get_logs_dir_path() file = "test.bin" binary_file = os.path.join(log_dir, file) with open(binary_file, "wb") as f: data = bytearray(i for i in range(256)) f.write(data) # Get the log def verify(): for read in get_log(node_ip=head_node["node_ip"], filename=file, encoding=None): assert read == data # Default utf-8 for read in get_log( node_ip=head_node["node_ip"], filename=file, errors="replace" ): assert read == data.decode(encoding="utf-8", errors="replace") for read in get_log( node_ip=head_node["node_ip"], filename=file, encoding="iso-8859-1", errors="replace", ): assert read == data.decode(encoding="iso-8859-1", errors="replace") return True wait_for_condition(verify) # Test running task logs @ray.remote def sleep_task(out_msg): print(out_msg, end="", file=sys.stdout) import time time.sleep(10) expected_out = "This is a test log from stdout\n" task = sleep_task.remote(expected_out) def verify(): lines = get_log(task_id=task.task_id().hex()) actual_out = "".join(lines) assert expected_out == actual_out, actual_out return True wait_for_condition(verify) @pytest.mark.skipif( sys.platform == "win32", reason="Windows has logging race from tasks." ) def test_log_task(shutdown_only): from ray.runtime_env import RuntimeEnv ray.init() # Test get log by multiple task id @ray.remote def task_log(): out_msg = "This is a test log from stdout\n" print(out_msg, end="", file=sys.stdout) err_msg = "THIS IS A TEST LOG FROM STDERR\n" print(err_msg, end="", file=sys.stderr) return out_msg, err_msg # Run some other tasks before and after to make sure task # log only outputs the task's log. ray.get(task_log.remote()) task = task_log.remote() expected_out, expected_err = ray.get(task) ray.get(task_log.remote()) def verify(): lines = get_log(task_id=task.task_id().hex()) assert expected_out in "".join(lines) # Test suffix lines = get_log(task_id=task.task_id().hex(), suffix="err") assert expected_err in "".join(lines) return True wait_for_condition(verify) enabled_actor_task_log_runtime_env = RuntimeEnv( env_vars={"RAY_ENABLE_RECORD_ACTOR_TASK_LOGGING": "1"} ) # Test actor task logs @ray.remote class Actor: def print_log(self, out_msg): for _ in range(3): print(out_msg, end="", file=sys.stdout) print(out_msg, end="", file=sys.stderr) a = Actor.options(runtime_env=enabled_actor_task_log_runtime_env).remote() out_msg = "This is a test log\n" t = a.print_log.remote(out_msg) ray.get(t) def verify(): lines = get_log(task_id=t.task_id().hex()) assert out_msg * 3 == "".join(lines) return True wait_for_condition(verify) # Test actor task logs with interleaving logs should raise # errors to ask users to user actor log instead. @ray.remote class AsyncActor: async def print_log(self, out_msg): for _ in range(3): print(out_msg, end="", file=sys.stdout) await asyncio.sleep(1) actor = AsyncActor.options( max_concurrency=2, runtime_env=enabled_actor_task_log_runtime_env ).remote() out_msg = "[{name}]: This is a test log from stdout\n" task_a = actor.print_log.remote(out_msg.format(name="a")) task_b = actor.print_log.remote(out_msg.format(name="b")) ray.get([task_a, task_b]) def verify(): lines = get_log(task_id=task_a.task_id().hex()) assert "".join(lines).count(out_msg.format(name="a")) == 3 return True wait_for_condition(verify) def verify_actor_task_error(task_id, actor_id): with pytest.raises(RayStateApiException) as e: for log in get_log(task_id=task_id): pass assert "For actor task, please query actor log" in str(e.value), str(e.value) assert f"ray logs actor --id {actor_id}" in str(e.value), str(e.value) return True # Getting task logs from actor with actor task log not enabled should raise errors. a = Actor.remote() t = a.print_log.remote(out_msg) ray.get(t) wait_for_condition( verify_actor_task_error, task_id=t.task_id().hex(), actor_id=a._actor_id.hex() ) a = AsyncActor.options(max_concurrency=2).remote() t = a.print_log.remote(out_msg) ray.get(t) wait_for_condition( verify_actor_task_error, task_id=t.task_id().hex(), actor_id=a._actor_id.hex() ) # Test task logs tail with lines. expected_out = [f"task-{i}\n" for i in range(5)] @ray.remote def f(): print("".join(expected_out), end="", file=sys.stdout) t = f.remote() ray.get(t) def verify(): lines = get_log(task_id=t.task_id().hex(), tail=2) actual_output = "".join(lines) assert actual_output == "".join(expected_out[-2:]) return True wait_for_condition(verify) def test_log_cli(shutdown_only): ray.init(num_cpus=1) runner = CliRunner() # Test the head node is chosen by default. def verify(): result = runner.invoke(logs_state_cli_group, ["cluster"]) assert result.exit_code == 0, result.exception assert "raylet.out" in result.output assert "raylet.err" in result.output assert "gcs_server.out" in result.output assert "gcs_server.err" in result.output return True wait_for_condition(verify) # Test when there's only 1 match, it prints logs. def verify(): result = runner.invoke(logs_state_cli_group, ["cluster", "raylet.out"]) assert result.exit_code == 0 assert "raylet.out" not in result.output assert "raylet.err" not in result.output assert "gcs_server.out" not in result.output assert "gcs_server.err" not in result.output # Make sure it prints the log message. assert "NodeManager server started" in result.output return True wait_for_condition(verify) # Test when there's more than 1 match, it prints a list of logs. def verify(): result = runner.invoke(logs_state_cli_group, ["cluster", "raylet.*"]) assert result.exit_code == 0, result.exception assert "raylet.out" in result.output assert "raylet.err" in result.output assert "gcs_server.out" not in result.output assert "gcs_server.err" not in result.output return True wait_for_condition(verify) # Test actor log: `ray logs actor` ACTOR_LOG_LINE = "test actor log" @ray.remote class Actor: def __init__(self): print(ACTOR_LOG_LINE) actor = Actor.remote() actor_id = actor._actor_id.hex() def verify(): result = runner.invoke(logs_state_cli_group, ["actor", "--id", actor_id]) assert result.exit_code == 0, result.exception assert ACTOR_LOG_LINE in result.output return True wait_for_condition(verify) # Test worker log: `ray logs worker` WORKER_LOG_LINE = "test worker log" @ray.remote def worker_func(): import os print(WORKER_LOG_LINE) return os.getpid() pid = ray.get(worker_func.remote()) def verify(): result = runner.invoke(logs_state_cli_group, ["worker", "--pid", pid]) assert result.exit_code == 0, result.exception assert WORKER_LOG_LINE in result.output return True wait_for_condition(verify) # Test `ray logs raylet.*` forwarding to `ray logs cluster raylet.*` def verify(): result = runner.invoke(logs_state_cli_group, ["raylet.*"]) assert result.exit_code == 0, result.exception assert "raylet.out" in result.output assert "raylet.err" in result.output assert "gcs_server.out" not in result.output assert "gcs_server.err" not in result.output return True wait_for_condition(verify) # Test binary binary files and encodings. log_dir = ray._private.worker.global_worker.node.get_logs_dir_path() file = "test.bin" binary_file = os.path.join(log_dir, file) with open(binary_file, "wb") as f: data = bytearray(i for i in range(256)) f.write(data) def verify(): # Tailing with lines is not supported for binary files, thus the `tail=-1` result = runner.invoke( logs_state_cli_group, [ file, "--encoding", "iso-8859-1", "--encoding-errors", "replace", "--tail", "-1", ], ) assert result.exit_code == 0, result.exception assert result.output == data.decode(encoding="iso-8859-1", errors="replace") return True wait_for_condition(verify) if __name__ == "__main__": sys.exit(pytest.main(["-sv", __file__]))