Skip to content

Commit 2e4911d

Browse files
authored
Structured logging mode (#7034)
1 parent 5378932 commit 2e4911d

File tree

4 files changed

+87
-6
lines changed

4 files changed

+87
-6
lines changed

openhands/core/logger.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,21 @@
66
import traceback
77
from datetime import datetime
88
from types import TracebackType
9-
from typing import Any, Literal, Mapping
9+
from typing import Any, Literal, Mapping, TextIO
1010

1111
import litellm
12+
from pythonjsonlogger.json import JsonFormatter
1213
from termcolor import colored
1314

1415
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
1516
DEBUG = os.getenv('DEBUG', 'False').lower() in ['true', '1', 'yes']
1617
DEBUG_LLM = os.getenv('DEBUG_LLM', 'False').lower() in ['true', '1', 'yes']
1718

19+
# Structured logs with JSON, disabled by default
20+
LOG_JSON = os.getenv('LOG_JSON', 'False').lower() in ['true', '1', 'yes']
21+
LOG_JSON_LEVEL_KEY = os.getenv('LOG_JSON_LEVEL_KEY', 'level')
22+
23+
1824
# Configure litellm logging based on DEBUG_LLM
1925
if DEBUG_LLM:
2026
confirmation = input(
@@ -294,10 +300,36 @@ def get_file_handler(
294300
file_name = f'openhands_{timestamp}.log'
295301
file_handler = logging.FileHandler(os.path.join(log_dir, file_name))
296302
file_handler.setLevel(log_level)
297-
file_handler.setFormatter(file_formatter)
303+
if LOG_JSON:
304+
file_handler.setFormatter(json_formatter())
305+
else:
306+
file_handler.setFormatter(file_formatter)
298307
return file_handler
299308

300309

310+
def json_formatter():
311+
return JsonFormatter(
312+
'{message}{levelname}',
313+
style='{',
314+
rename_fields={'levelname': LOG_JSON_LEVEL_KEY},
315+
timestamp=True,
316+
)
317+
318+
319+
def json_log_handler(
320+
level: int = logging.INFO,
321+
_out: TextIO = sys.stdout,
322+
) -> logging.Handler:
323+
"""
324+
Configure logger instance for structured logging as json lines.
325+
"""
326+
327+
handler = logging.StreamHandler(_out)
328+
handler.setLevel(level)
329+
handler.setFormatter(json_formatter())
330+
return handler
331+
332+
301333
# Set up logging
302334
logging.basicConfig(level=logging.ERROR)
303335

@@ -335,7 +367,11 @@ def log_uncaught_exceptions(
335367
LOG_TO_FILE = True
336368
openhands_logger.debug('DEBUG mode enabled.')
337369

338-
openhands_logger.addHandler(get_console_handler(current_log_level))
370+
if LOG_JSON:
371+
openhands_logger.addHandler(json_log_handler(current_log_level))
372+
else:
373+
openhands_logger.addHandler(get_console_handler(current_log_level))
374+
339375
openhands_logger.addFilter(SensitiveDataFilter(openhands_logger.name))
340376
openhands_logger.propagate = False
341377
openhands_logger.debug('Logging initialized')

poetry.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ ipywidgets = "^8.1.5"
7777
qtconsole = "^5.6.1"
7878
memory-profiler = "^0.61.0"
7979
daytona-sdk = "0.9.1"
80+
python-json-logger = "^3.2.1"
8081

8182
[tool.poetry.group.llama-index.dependencies]
8283
llama-index = "*"
@@ -109,6 +110,7 @@ reportlab = "*"
109110
[tool.coverage.run]
110111
concurrency = ["gevent"]
111112

113+
112114
[tool.poetry.group.runtime.dependencies]
113115
jupyterlab = "*"
114116
notebook = "*"
@@ -137,6 +139,7 @@ ignore = ["D1"]
137139
[tool.ruff.lint.pydocstyle]
138140
convention = "google"
139141

142+
140143
[tool.poetry.group.evaluation.dependencies]
141144
streamlit = "*"
142145
whatthepatch = "*"

tests/unit/test_logging.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import json
12
import logging
23
from io import StringIO
34
from unittest.mock import patch
45

56
import pytest
67

78
from openhands.core.config import AppConfig, LLMConfig
9+
from openhands.core.logger import json_log_handler
810
from openhands.core.logger import openhands_logger as openhands_logger
911

1012

@@ -20,6 +22,15 @@ def test_handler():
2022
openhands_logger.removeHandler(handler)
2123

2224

25+
@pytest.fixture
26+
def json_handler():
27+
stream = StringIO()
28+
json_handler = json_log_handler(logging.INFO, _out=stream)
29+
openhands_logger.addHandler(json_handler)
30+
yield openhands_logger, stream
31+
openhands_logger.removeHandler(json_handler)
32+
33+
2334
def test_openai_api_key_masking(test_handler):
2435
logger, stream = test_handler
2536

@@ -118,3 +129,34 @@ def test_special_cases_masking(test_handler):
118129
log_output = stream.getvalue()
119130
for attr, value in environ.items():
120131
assert value not in log_output
132+
133+
134+
class TestLogOutput:
135+
def test_info(self, json_handler):
136+
logger, string_io = json_handler
137+
138+
logger.info('Test message')
139+
output = json.loads(string_io.getvalue())
140+
assert 'timestamp' in output
141+
del output['timestamp']
142+
assert output == {'message': 'Test message', 'level': 'INFO'}
143+
144+
def test_error(self, json_handler):
145+
logger, string_io = json_handler
146+
147+
logger.error('Test message')
148+
output = json.loads(string_io.getvalue())
149+
del output['timestamp']
150+
assert output == {'message': 'Test message', 'level': 'ERROR'}
151+
152+
def test_extra_fields(self, json_handler):
153+
logger, string_io = json_handler
154+
155+
logger.info('Test message', extra={'key': '..val..'})
156+
output = json.loads(string_io.getvalue())
157+
del output['timestamp']
158+
assert output == {
159+
'key': '..val..',
160+
'message': 'Test message',
161+
'level': 'INFO',
162+
}

0 commit comments

Comments
 (0)