Skip to content

Commit e0c29d3

Browse files
authored
Server Logging (#119)
* Implement server logging * Use new logger adapters across project * Add server logging section to README.md * logging: allow not sending extra to server * Server Logging: Change environment variable name * ruby: use logger adapter * gprofiler: bump version
1 parent 376e95b commit e0c29d3

File tree

16 files changed

+370
-61
lines changed

16 files changed

+370
-61
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ Profiling using eBPF incurs lower overhead & provides kernel stacks. This (curre
7676
* `smart` - Run both `fp` and `dwarf`, then choose the result with the highest average of stack frames count, per process.
7777
* `none` - Avoids running `perf` at all. See [perf-less mode](#perf-less-mode).
7878

79+
## Other options
80+
### Sending logs to server
81+
**By default, gProfiler sends logs to Granulate Performance Studio** (when using `--upload-results`/`-u` flag)
82+
This behavior can be disabled by passing `--dont-send-logs` or the setting environment variable `GPROFILER_DONT_SEND_LOGS=1`.
83+
7984
### Continuous mode
8085
gProfiler can be run in a continuous mode, profiling periodically, using the `--continuous`/`-c` flag.
8186
Note that when using `--continuous` with `--output-dir`, a new file will be created during *each* sampling interval.

gprofiler/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.1.3"
1+
__version__ = "1.1.4"

gprofiler/client.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,24 @@
55
import datetime
66
import gzip
77
import json
8-
import logging
98
from io import BytesIO
109
from typing import Dict, List, Optional, Tuple
1110

1211
import requests
1312
from requests import Session
1413

1514
from gprofiler import __version__
15+
from gprofiler.exceptions import APIError
16+
from gprofiler.log import get_logger_adapter
1617
from gprofiler.utils import get_iso8601_format_time
1718

18-
logger = logging.getLogger(__name__)
19+
logger = get_logger_adapter(__name__)
1920

2021
GRANULATE_SERVER_HOST = "https://profiler.granulate.io"
2122
DEFAULT_REQUEST_TIMEOUT = 5
2223
DEFAULT_UPLOAD_TIMEOUT = 120
2324

2425

25-
class APIError(Exception):
26-
def __init__(self, message: str, full_data: dict = None):
27-
self.message = message
28-
self.full_data = full_data
29-
30-
def __str__(self):
31-
return self.message
32-
33-
3426
class APIClient:
3527
BASE_PATH = "api"
3628

gprofiler/docker_client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22
# Copyright (c) Granulate. All rights reserved.
33
# Licensed under the AGPL3 License. See LICENSE.md in the project root for license information.
44
#
5-
import logging
65
import re
76
from typing import Dict, List, Optional, Set
87

98
import docker
109

10+
from gprofiler.log import get_logger_adapter
11+
1112
DOCKER_SYSTEMD_CGROUPS = [re.compile(r"/system.slice/docker-([a-z0-9]{64})\.scope")]
1213

13-
logger = logging.getLogger(__name__)
14+
logger = get_logger_adapter(__name__)
1415

1516

1617
class DockerClient:

gprofiler/exceptions.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,20 @@ def __str__(self):
2929
class ProgramMissingException(Exception):
3030
def __init__(self, program: str):
3131
super().__init__(f"The program {program!r} is missing! Please install it")
32+
33+
34+
class APIError(Exception):
35+
def __init__(self, message: str, full_data: dict = None):
36+
self.message = message
37+
self.full_data = full_data
38+
39+
def __str__(self):
40+
return self.message
41+
42+
43+
class UninitializedStateException(Exception):
44+
pass
45+
46+
47+
class StateAlreadyInitializedException(Exception):
48+
pass

gprofiler/java.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import concurrent.futures
66
import errno
77
import functools
8-
import logging
98
import os
109
import shutil
1110
from pathlib import Path
@@ -16,6 +15,7 @@
1615
from psutil import Process
1716

1817
from gprofiler.exceptions import CalledProcessError, StopEventSetException
18+
from gprofiler.log import get_logger_adapter
1919
from gprofiler.merge import parse_one_collapsed
2020
from gprofiler.profiler_base import ProfilerBase
2121
from gprofiler.types import ProcessToStackSampleCounters
@@ -32,7 +32,7 @@
3232
touch_path,
3333
)
3434

35-
logger = logging.getLogger(__name__)
35+
logger = get_logger_adapter(__name__)
3636

3737

3838
class JattachException(CalledProcessError):

gprofiler/log.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import datetime
2+
import json
3+
import logging
4+
import logging.handlers
5+
import os
6+
import re
7+
import sys
8+
from logging import Logger, LogRecord
9+
from typing import TYPE_CHECKING, Any, Dict, List, MutableMapping, Optional, Tuple
10+
11+
from requests import RequestException
12+
13+
from gprofiler.exceptions import APIError
14+
from gprofiler.state import State, UninitializedStateException, get_state
15+
16+
if TYPE_CHECKING:
17+
from gprofiler.client import APIClient
18+
19+
NO_SERVER_LOG_KEY = "no_server_log"
20+
NO_SERVER_EXTRA_KEY = "no_extra_to_server"
21+
RUN_ID_KEY = "run_id"
22+
CYCLE_ID_KEY = "cycle_id"
23+
LOGGER_NAME_RE = re.compile(r"gprofiler(?:\..+)?")
24+
25+
26+
def get_logger_adapter(logger_name: str) -> logging.LoggerAdapter:
27+
# Validate the name starts with gprofiler (the root logger name), so logging parent logger propagation will work.
28+
assert LOGGER_NAME_RE.match(logger_name) is not None, "logger name must start with 'gprofiler'"
29+
logger = logging.getLogger(logger_name)
30+
return GProfilerLoggingAdapter(logger)
31+
32+
33+
class GProfilerLoggingAdapter(logging.LoggerAdapter):
34+
LOGGING_KWARGS = ["exc_info", "extra", "stack_info"]
35+
36+
def __init__(self, logger: Logger) -> None:
37+
super().__init__(logger, {})
38+
# Lazy initialization of state because it will be initialized only after calling `init_state` while the adapter
39+
# may initialize before (at module import stage)
40+
self._state: Optional[State] = None
41+
42+
def _get_generic_extra(self) -> Dict[str, str]:
43+
if self._state is None:
44+
try:
45+
self._state = get_state()
46+
except UninitializedStateException:
47+
return {}
48+
49+
generic_extra = {
50+
RUN_ID_KEY: self._state.run_id,
51+
}
52+
53+
if self._state.cycle_id:
54+
generic_extra[CYCLE_ID_KEY] = self._state.cycle_id
55+
56+
return generic_extra
57+
58+
def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> Tuple[Any, MutableMapping[str, Any]]:
59+
extra_kwargs = {}
60+
logging_kwargs = {}
61+
for k, v in kwargs.items():
62+
if k in self.LOGGING_KWARGS:
63+
logging_kwargs[k] = v
64+
else:
65+
extra_kwargs[k] = v
66+
67+
extra_kwargs.update(self._get_generic_extra())
68+
69+
extra = logging_kwargs.get("extra", {})
70+
extra["gprofiler_adapter_extra"] = extra_kwargs
71+
logging_kwargs["extra"] = extra
72+
return msg, logging_kwargs
73+
74+
def debug(self, msg: Any, *args, no_server_log: bool = False, **kwargs) -> None:
75+
super().debug(msg, *args, no_server_log=no_server_log, **kwargs)
76+
77+
def info(self, msg: Any, *args, no_server_log: bool = False, **kwargs) -> None:
78+
super().info(msg, *args, no_server_log=no_server_log, **kwargs)
79+
80+
def warning(self, msg: Any, *args, no_server_log: bool = False, **kwargs) -> None:
81+
super().warning(msg, *args, no_server_log=no_server_log, **kwargs)
82+
83+
def warn(self, msg: Any, *args, no_server_log: bool = False, **kwargs) -> None:
84+
super().warn(msg, *args, no_server_log=no_server_log, **kwargs)
85+
86+
def error(self, msg: Any, *args, no_server_log: bool = False, **kwargs) -> None:
87+
super().error(msg, *args, no_server_log=no_server_log, **kwargs)
88+
89+
def exception(self, msg: Any, *args, no_server_log: bool = False, **kwargs) -> None:
90+
super().exception(msg, *args, no_server_log=no_server_log, **kwargs)
91+
92+
def critical(self, msg: Any, *args, no_server_log: bool = False, **kwargs) -> None:
93+
super().critical(msg, *args, no_server_log=no_server_log, **kwargs)
94+
95+
def log(self, level: int, msg: Any, *args, no_server_log: bool = False, **kwargs) -> None:
96+
super().log(level, msg, *args, no_server_log=no_server_log, **kwargs)
97+
98+
99+
class RemoteLogsHandler(logging.Handler):
100+
"""
101+
logging.Handler that supports accumulating logs and sending them to server upon request.
102+
Because we don't want to lose log records before the APIClient initialized we support lazy initialization of
103+
APIClient, while logs are still accumulated from the beginning.
104+
"""
105+
106+
def __init__(self, path: str = "logs", api_client: Optional['APIClient'] = None) -> None:
107+
super().__init__(logging.DEBUG)
108+
self._api_client = api_client
109+
self._path = path
110+
self._logs: List[Dict] = []
111+
self._logger = get_logger_adapter("gprofiler.RemoteLogsHandler")
112+
113+
# The formatter is needed to format tracebacks
114+
self.setFormatter(logging.Formatter())
115+
116+
def init_api_client(self, api_client: 'APIClient'):
117+
self._api_client = api_client
118+
119+
def emit(self, record: LogRecord) -> None:
120+
if record.gprofiler_adapter_extra.pop(NO_SERVER_LOG_KEY, False): # type: ignore
121+
return
122+
123+
self._logs.append(self._make_dict_record(record))
124+
125+
def _make_dict_record(self, record: LogRecord):
126+
formatted_timestamp = datetime.datetime.utcfromtimestamp(record.created).isoformat()
127+
extra = record.gprofiler_adapter_extra # type: ignore
128+
129+
# We don't want to serialize a JSON inside JSON but either don't want to fail record because of extra
130+
# serialization, so we test if the extra can be serialized and have a fail-safe.
131+
try:
132+
json.dumps(extra)
133+
except TypeError:
134+
self._logger.exception(
135+
f"Can't serialize extra (extra={extra!r}), sending empty extra", bad_extra=repr(extra)
136+
)
137+
extra = {}
138+
139+
run_id = extra.pop(RUN_ID_KEY, None)
140+
if run_id is None:
141+
self._logger.error("state.run_id is not defined! probably a bug!")
142+
run_id = ''
143+
144+
cycle_id = extra.pop(CYCLE_ID_KEY, '')
145+
146+
assert self.formatter is not None
147+
if record.exc_info:
148+
# Use cached exc_text if available.
149+
exception_traceback = (
150+
record.exc_text if record.exc_text else self.formatter.formatException(record.exc_info)
151+
)
152+
else:
153+
exception_traceback = ''
154+
155+
extra = extra if not extra.pop(NO_SERVER_EXTRA_KEY, False) else {}
156+
157+
return {
158+
"message": record.message,
159+
"level": record.levelname,
160+
"timestamp": formatted_timestamp,
161+
"extra": extra,
162+
"logger_name": record.name,
163+
"exception": exception_traceback,
164+
RUN_ID_KEY: run_id,
165+
CYCLE_ID_KEY: cycle_id,
166+
}
167+
168+
def try_send_log_to_server(self):
169+
assert self._api_client is not None, "APIClient is not initialized, can't send logs to server"
170+
# Snapshot the current num logs because logs list might be extended meanwhile.
171+
logs_count = len(self._logs)
172+
try:
173+
self._api_client.post(self._path, data=self._logs[:logs_count], api_version='v1')
174+
except (APIError, RequestException):
175+
self._logger.exception("Failed sending logs to server")
176+
else:
177+
# If succeeded, remove the sent logs from the list.
178+
self._logs[:logs_count] = []
179+
180+
181+
class ExtraFormatter(logging.Formatter):
182+
FILTERED_EXTRA_KEYS = [CYCLE_ID_KEY, RUN_ID_KEY, NO_SERVER_LOG_KEY, NO_SERVER_EXTRA_KEY]
183+
184+
def format(self, record: LogRecord) -> str:
185+
formatted = super().format(record)
186+
extra = record.gprofiler_adapter_extra # type: ignore
187+
188+
formatted_extra = ", ".join(f"{k}={v}" for k, v in extra.items() if k not in self.FILTERED_EXTRA_KEYS)
189+
if formatted_extra:
190+
formatted = f"{formatted} ({formatted_extra})"
191+
192+
return formatted
193+
194+
195+
def initial_root_logger_setup(
196+
stream_level: int,
197+
log_file_path: str,
198+
rotate_max_bytes: int,
199+
rotate_backup_count: int,
200+
remote_logs_handler: Optional[RemoteLogsHandler],
201+
) -> logging.LoggerAdapter:
202+
logger_adapter = get_logger_adapter("gprofiler")
203+
logger_adapter.setLevel(logging.DEBUG)
204+
205+
stream_handler = logging.StreamHandler(stream=sys.stdout)
206+
stream_handler.setLevel(stream_level)
207+
if stream_level < logging.INFO:
208+
stream_handler.setFormatter(ExtraFormatter("[%(asctime)s] %(levelname)s: %(name)s: %(message)s"))
209+
else:
210+
stream_handler.setFormatter(ExtraFormatter("[%(asctime)s] %(message)s", "%H:%M:%S"))
211+
logger_adapter.logger.addHandler(stream_handler)
212+
213+
os.makedirs(os.path.dirname(log_file_path), exist_ok=True)
214+
file_handler = logging.handlers.RotatingFileHandler(
215+
log_file_path,
216+
maxBytes=rotate_max_bytes,
217+
backupCount=rotate_backup_count,
218+
)
219+
file_handler.setLevel(logging.DEBUG)
220+
file_handler.setFormatter(ExtraFormatter("[%(asctime)s] %(levelname)s: %(name)s: %(message)s"))
221+
logger_adapter.logger.addHandler(file_handler)
222+
223+
if remote_logs_handler is not None:
224+
logger_adapter.logger.addHandler(remote_logs_handler)
225+
226+
return logger_adapter

0 commit comments

Comments
 (0)