Skip to content

Commit f4f3088

Browse files
[Feature]: Default CLI log level is DEBUG; WARNING and above go to STDOUT, DEBUG logs to a file (#2940)
* [Feature]: Default CLI log level is DEBUG; WARNING and above go to STDOUT, DEBUG logs to a file #2939 * Update docs/docs/guides/troubleshooting.md Co-authored-by: Victor Skvortsov <vds003@gmail.com> * [Feature]: Default CLI log level is DEBUG; WARNING and above go to STDOUT, DEBUG logs to a file #2939 Review feedback --------- Co-authored-by: Victor Skvortsov <vds003@gmail.com>
1 parent c376500 commit f4f3088

File tree

5 files changed

+180
-8
lines changed

5 files changed

+180
-8
lines changed

docs/docs/guides/troubleshooting.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@ If you have a question or need help, feel free to ask it in our [Discord server]
1414
Make sure to provide clear, detailed steps to reproduce the issue.
1515
Include server logs, CLI outputs, and configuration samples. Avoid using screenshots for logs or errors—use text instead.
1616

17-
To get more detailed logs, make sure to set the `DSTACK_CLI_LOG_LEVEL` and `DSTACK_SERVER_LOG_LEVEL`
18-
environment variables to `debug` when running the CLI and the server, respectively.
17+
#### Server logs
18+
19+
To get more detailed server logs, set the `DSTACK_SERVER_LOG_LEVEL`
20+
environment variable to `DEBUG`. By default, it is set to `INFO`.
21+
22+
#### CLI logs
23+
24+
CLI logs are located in `~/.dstack/logs/cli`, and the default log level is `DEBUG`.
1925

2026
> See these examples for well-reported issues: [this :material-arrow-top-right-thin:{ .external }](https://github.com/dstackai/dstack/issues/1640){:target="_blank"}
2127
and [this :material-arrow-top-right-thin:{ .external }](https://github.com/dstackai/dstack/issues/1551){:target="_blank"}.

docs/docs/reference/environment-variables.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ For more details on the options below, refer to the [server deployment](../guide
147147

148148
The following environment variables are supported by the CLI.
149149

150-
- `DSTACK_CLI_LOG_LEVEL`{ #DSTACK_CLI_LOG_LEVEL } – Configures CLI logging level. Defaults to `INFO`.
150+
- `DSTACK_CLI_LOG_LEVEL`{ #DSTACK_CLI_LOG_LEVEL } – Sets the logging level for CLI output to stdout. Defaults to `INFO`.
151151

152152
Example:
153153

@@ -159,4 +159,17 @@ $ DSTACK_CLI_LOG_LEVEL=debug dstack apply -f .dstack.yml
159159

160160
</div>
161161

162+
- `DSTACK_CLI_FILE_LOG_LEVEL`{ #DSTACK_CLI_FILE_LOG_LEVEL } – Sets the logging level for CLI log files. Defaults to `DEBUG`.
163+
164+
<div class="termy">
165+
166+
```shell
167+
$ find ~/.dstack/logs/cli/
168+
169+
~/.dstack/logs/cli/latest.log
170+
~/.dstack/logs/cli/2025-07-31.log
171+
```
172+
173+
</div>
174+
162175
- `DSTACK_PROJECT`{ #DSTACK_PROJECT } – Has the same effect as `--project`. Defaults to `None`.

src/dstack/_internal/cli/utils/common.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
22
import os
3+
from datetime import datetime, timezone
4+
from pathlib import Path
35
from typing import Any, Dict, Union
46

57
from rich.console import Console
@@ -9,6 +11,7 @@
911

1012
from dstack._internal.cli.utils.rich import DstackRichHandler
1113
from dstack._internal.core.errors import CLIError, DstackError
14+
from dstack._internal.utils.common import get_dstack_dir
1215

1316
_colors = {
1417
"secondary": "grey58",
@@ -35,12 +38,59 @@ def cli_error(e: DstackError) -> CLIError:
3538
return CLIError(*e.args)
3639

3740

41+
def _get_cli_log_file() -> Path:
42+
"""Get the CLI log file path, rotating the previous log if needed."""
43+
log_dir = get_dstack_dir() / "logs" / "cli"
44+
log_file = log_dir / "latest.log"
45+
46+
if log_file.exists():
47+
file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime, tz=timezone.utc)
48+
current_date = datetime.now(timezone.utc).date()
49+
50+
if file_mtime.date() < current_date:
51+
date_str = file_mtime.strftime("%Y-%m-%d")
52+
rotated_file = log_dir / f"{date_str}.log"
53+
54+
counter = 1
55+
while rotated_file.exists():
56+
rotated_file = log_dir / f"{date_str}-{counter}.log"
57+
counter += 1
58+
59+
log_file.rename(rotated_file)
60+
61+
log_dir.mkdir(parents=True, exist_ok=True)
62+
return log_file
63+
64+
3865
def configure_logging():
3966
dstack_logger = logging.getLogger("dstack")
40-
dstack_logger.setLevel(os.getenv("DSTACK_CLI_LOG_LEVEL", "INFO").upper())
41-
handler = DstackRichHandler(console=console)
42-
handler.setFormatter(logging.Formatter(fmt="%(message)s", datefmt="[%X]"))
43-
dstack_logger.addHandler(handler)
67+
dstack_logger.handlers.clear()
68+
69+
log_file = _get_cli_log_file()
70+
71+
level_names = logging.getLevelNamesMapping()
72+
stdout_level_name = os.getenv("DSTACK_CLI_LOG_LEVEL", "INFO").upper()
73+
stdout_level = level_names[stdout_level_name]
74+
dstack_logger.setLevel(stdout_level)
75+
76+
stdout_handler = DstackRichHandler(console=console)
77+
stdout_handler.setFormatter(logging.Formatter(fmt="%(message)s", datefmt="[%X]"))
78+
stdout_handler.setLevel(stdout_level)
79+
dstack_logger.addHandler(stdout_handler)
80+
81+
file_level_name = os.getenv("DSTACK_CLI_FILE_LOG_LEVEL", "DEBUG").upper()
82+
file_level = level_names[file_level_name]
83+
84+
file_handler = logging.FileHandler(log_file)
85+
file_handler.setFormatter(
86+
logging.Formatter(
87+
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
88+
)
89+
)
90+
file_handler.setLevel(file_level)
91+
dstack_logger.addHandler(file_handler)
92+
93+
dstack_logger.setLevel(min(stdout_level, file_level))
4494

4595

4696
def confirm_ask(prompt, **kwargs) -> bool:

src/dstack/api/server/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ def _request(
173173
raise ClientError(
174174
f"Unexpected error: status code {resp.status_code}"
175175
f" when requesting {resp.request.url}."
176-
" Check server logs or run with DSTACK_CLI_LOG_LEVEL=DEBUG to see more details"
176+
" Check the server logs for backend issues, and the CLI logs at (~/.dstack/logs/cli/latest.log) local CLI output"
177177
)
178178
return resp
179179

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import os
2+
from datetime import datetime, timedelta, timezone
3+
from pathlib import Path
4+
from typing import Generator
5+
from unittest.mock import patch
6+
7+
import pytest
8+
9+
from dstack._internal.cli.utils.common import _get_cli_log_file
10+
11+
12+
@pytest.fixture
13+
def mock_dstack_dir(tmp_path: Path) -> Generator[Path, None, None]:
14+
with patch("dstack._internal.cli.utils.common.get_dstack_dir") as mock:
15+
mock.return_value = tmp_path
16+
yield tmp_path
17+
18+
19+
class TestGetCliLogFile:
20+
def test_no_existing_dir(self, mock_dstack_dir: Path):
21+
log_dir = mock_dstack_dir / "logs" / "cli"
22+
expected_log_file = log_dir / "latest.log"
23+
assert not log_dir.exists()
24+
25+
result = _get_cli_log_file()
26+
27+
assert log_dir.exists()
28+
assert result == expected_log_file
29+
30+
def test_no_rotation_needed_for_today_file(self, mock_dstack_dir: Path):
31+
log_dir = mock_dstack_dir / "logs" / "cli"
32+
log_dir.mkdir(parents=True, exist_ok=True)
33+
latest_log = log_dir / "latest.log"
34+
latest_log.touch()
35+
36+
result = _get_cli_log_file()
37+
38+
assert result == latest_log
39+
assert latest_log.exists(), "latest.log should not have been renamed"
40+
41+
@patch("dstack._internal.cli.utils.common.datetime")
42+
def test_simple_rotation(self, mock_datetime, mock_dstack_dir: Path):
43+
# Mock "now" to be a specific date
44+
now = datetime(2023, 10, 27, 10, 0, 0, tzinfo=timezone.utc)
45+
mock_datetime.now.return_value = now
46+
# Ensure fromtimestamp still works correctly for the System Under Test
47+
mock_datetime.fromtimestamp.side_effect = lambda ts, tz: datetime.fromtimestamp(ts, tz)
48+
49+
log_dir = mock_dstack_dir / "logs" / "cli"
50+
log_dir.mkdir(parents=True, exist_ok=True)
51+
latest_log = log_dir / "latest.log"
52+
latest_log.touch()
53+
54+
# Set the modification time to yesterday
55+
yesterday = now - timedelta(days=1)
56+
mtime = yesterday.timestamp()
57+
os.utime(latest_log, (mtime, mtime))
58+
59+
# The expected rotated file name is based on the modification time (yesterday)
60+
date_str = yesterday.strftime("%Y-%m-%d")
61+
expected_rotated_log = log_dir / f"{date_str}.log"
62+
63+
result = _get_cli_log_file()
64+
65+
assert result == log_dir / "latest.log"
66+
assert not latest_log.exists(), "The original latest.log should have been renamed"
67+
assert expected_rotated_log.exists(), "The log file should have been rotated"
68+
69+
@patch("dstack._internal.cli.utils.common.datetime")
70+
def test_rotation_with_conflict(self, mock_datetime, mock_dstack_dir: Path):
71+
now = datetime(2023, 10, 27, 10, 0, 0, tzinfo=timezone.utc)
72+
yesterday = now - timedelta(days=1)
73+
mock_datetime.now.return_value = now
74+
mock_datetime.fromtimestamp.side_effect = lambda ts, tz: datetime.fromtimestamp(ts, tz)
75+
76+
log_dir = mock_dstack_dir / "logs" / "cli"
77+
log_dir.mkdir(parents=True, exist_ok=True)
78+
79+
# Create the old 'latest.log' and set its modification time to yesterday
80+
latest_log = log_dir / "latest.log"
81+
latest_log.touch()
82+
mtime = yesterday.timestamp()
83+
os.utime(latest_log, (mtime, mtime))
84+
85+
# Create conflicting files that already exist from a previous rotation
86+
date_str = yesterday.strftime("%Y-%m-%d")
87+
conflicting_log_1 = log_dir / f"{date_str}.log"
88+
conflicting_log_1.touch()
89+
conflicting_log_2 = log_dir / f"{date_str}-1.log"
90+
conflicting_log_2.touch()
91+
92+
# We expect the file to be rotated to the next available counter
93+
expected_rotated_log = log_dir / f"{date_str}-2.log"
94+
95+
result = _get_cli_log_file()
96+
97+
assert result == log_dir / "latest.log"
98+
assert not latest_log.exists(), "The original latest.log should have been renamed"
99+
assert conflicting_log_1.exists(), "Existing rotated log should be untouched"
100+
assert conflicting_log_2.exists(), "Existing rotated log with counter should be untouched"
101+
assert expected_rotated_log.exists(), (
102+
"The log should have rotated to the next available counter"
103+
)

0 commit comments

Comments
 (0)