Просмотр исходного кода

Merge pull request #1934 from pbiering/add-profiling

Add profiling support
Peter Bieringer 2 месяцев назад
Родитель
Сommit
e1b3828bd7
6 измененных файлов с 197 добавлено и 1 удалено
  1. 1 0
      CHANGELOG.md
  2. 38 0
      DOCUMENTATION.md
  3. 14 0
      config
  4. 24 0
      contrib/logwatch/radicale
  5. 96 1
      radicale/app/__init__.py
  6. 24 0
      radicale/config.py

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@
 * Add: logging of broken contact items during PUT
 * Add: logging of broken contact items during PUT
 * Extend: [auth] imap: add fallback support for LOGIN towards remote IMAP server (replaced in 3.5.0)
 * Extend: [auth] imap: add fallback support for LOGIN towards remote IMAP server (replaced in 3.5.0)
 * Fix: improper detection of HTTP_X_FORWARDED_PORT on MOVE
 * Fix: improper detection of HTTP_X_FORWARDED_PORT on MOVE
+* Extend: [logging] with profiling log per reqest or regular per request method
 
 
 ## 3.5.9
 ## 3.5.9
 * Extend: [auth] add support for type http_remote_user
 * Extend: [auth] add support for type http_remote_user

+ 38 - 0
DOCUMENTATION.md

@@ -1690,6 +1690,44 @@ Log storage cache actions on `level = debug`
 
 
 Default: `False`
 Default: `False`
 
 
+##### profiling_per_request
+
+_(>= 3.5.10)_
+
+Log profiling data on level=info
+
+Default: `none`
+
+One of
+* `none` (disabled)
+* `per_request` (above minimum duration)
+* `per_request_method` (regular interval)
+
+##### profiling_per_request_min_duration
+
+_(>= 3.5.10)_
+
+Log profiling data per request minimum duration (seconds) before logging, otherwise skip
+
+Default: `3`
+
+##### profiling_per_request_method_interval
+
+_(>= 3.5.10)_
+
+Log profiling data per method interval (seconds)
+Triggered by request, not active on idle systems
+
+Default: `600`
+
+##### profiling_top_x_functions
+
+_(>= 3.5.10)_
+
+Log profiling top X functions (limit)
+
+Default: `10`
+
 #### [headers]
 #### [headers]
 
 
 This section can be used to specify additional HTTP headers that will be sent to clients.
 This section can be used to specify additional HTTP headers that will be sent to clients.

+ 14 - 0
config

@@ -321,6 +321,20 @@
 # Log storage cache actions on level=debug
 # Log storage cache actions on level=debug
 #storage_cache_actions_on_debug = False
 #storage_cache_actions_on_debug = False
 
 
+# Log profiling data on level=info
+# Value: per_request | per_request_method | none
+#profiling = none
+
+# Log profiling data per request minium duration (seconds)
+#profiling_per_request_min_duration = 3
+
+# Log profiling data per request method interval (seconds)
+#profiling_per_request_method_interval = 600
+
+# Log profiling top X functions (limit)
+#profiling_top_x_functions = 10
+
+
 [headers]
 [headers]
 
 
 # Additional HTTP headers
 # Additional HTTP headers

+ 24 - 0
contrib/logwatch/radicale

@@ -126,11 +126,35 @@ while (defined($ThisLine = <STDIN>)) {
    elsif ( $ThisLine =~ / (Failed login attempt) /o ) {
    elsif ( $ThisLine =~ / (Failed login attempt) /o ) {
       $OtherEvents{$1}++;
       $OtherEvents{$1}++;
    }
    }
+   elsif ( $ThisLine =~ / (Profiling data per request method \S+) /o ) {
+      my $info = $1;
+      if ( $ThisLine =~ /(no request seen so far)/o ) {
+         $OtherEvents{$info . " - " . $1}++;
+      } else {
+         $OtherEvents{$info}++;
+      };
+   }
+   elsif ( $ThisLine =~ / (Profiling data per request \S+) /o ) {
+      my $info = $1;
+      if ( $ThisLine =~ /(suppressed because duration below minimum|suppressed because of no data)/o ) {
+         $OtherEvents{$info . " - " . $1}++;
+      } else {
+         $OtherEvents{$info}++;
+      };
+   }
    elsif ( $ThisLine =~ /\[(DEBUG|INFO)\] /o ) {
    elsif ( $ThisLine =~ /\[(DEBUG|INFO)\] /o ) {
       # skip if DEBUG+INFO
       # skip if DEBUG+INFO
    }
    }
    else {
    else {
       # Report any unmatched entries...
       # Report any unmatched entries...
+      if ($ThisLine =~ /^({\'| )/o) {
+         # skip profiling or raw header data
+         next;
+      };
+      if ($ThisLine =~ /^$/o) {
+         # skip empty line
+         next;
+      };
       $ThisLine =~ s/^\[\d+(\/Thread-\d+)?\] //; # remove process/Thread ID
       $ThisLine =~ s/^\[\d+(\/Thread-\d+)?\] //; # remove process/Thread ID
       chomp($ThisLine);
       chomp($ThisLine);
       $OtherList{$ThisLine}++;
       $OtherList{$ThisLine}++;

+ 96 - 1
radicale/app/__init__.py

@@ -27,8 +27,11 @@ the built-in server (see ``radicale.server`` module).
 """
 """
 
 
 import base64
 import base64
+import cProfile
 import datetime
 import datetime
+import io
 import pprint
 import pprint
+import pstats
 import random
 import random
 import time
 import time
 import zlib
 import zlib
@@ -55,6 +58,8 @@ from radicale.log import logger
 # Combination of types.WSGIStartResponse and WSGI application return value
 # Combination of types.WSGIStartResponse and WSGI application return value
 _IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]]
 _IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]]
 
 
+REQUEST_METHODS = ["DELETE", "GET", "HEAD", "MKCALENDAR", "MKCOL", "MOVE", "OPTIONS", "POST", "PROPFIND", "PROPPATCH", "PUT", "REPORT"]
+
 
 
 class Application(ApplicationPartDelete, ApplicationPartHead,
 class Application(ApplicationPartDelete, ApplicationPartHead,
                   ApplicationPartGet, ApplicationPartMkcalendar,
                   ApplicationPartGet, ApplicationPartMkcalendar,
@@ -73,6 +78,12 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
     _web_type: str
     _web_type: str
     _script_name: str
     _script_name: str
     _extra_headers: Mapping[str, str]
     _extra_headers: Mapping[str, str]
+    _profiling_per_request: bool = False
+    _profiling_per_request_method: bool = False
+    profiler_per_request_method: dict[str, cProfile.Profile] = {}
+    profiler_per_request_method_counter: dict[str, int] = {}
+    profiler_per_request_method_starttime: datetime.datetime
+    profiler_per_request_method_logtime: datetime.datetime
 
 
     def __init__(self, configuration: config.Configuration) -> None:
     def __init__(self, configuration: config.Configuration) -> None:
         """Initialize Application.
         """Initialize Application.
@@ -116,6 +127,51 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
             self._extra_headers[key] = configuration.get("headers", key)
             self._extra_headers[key] = configuration.get("headers", key)
         self._strict_preconditions = configuration.get("storage", "strict_preconditions")
         self._strict_preconditions = configuration.get("storage", "strict_preconditions")
         logger.info("strict preconditions check: %s", self._strict_preconditions)
         logger.info("strict preconditions check: %s", self._strict_preconditions)
+        # Profiling options
+        self._profiling = configuration.get("logging", "profiling")
+        self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration")
+        self._profiling_per_request_method_interval = configuration.get("logging", "profiling_per_request_method_interval")
+        self._profiling_top_x_functions = configuration.get("logging", "profiling_top_x_functions")
+        if self._profiling in config.PROFILING:
+            logger.info("profiling: %s", self._profiling)
+            if self._profiling == "per_request":
+                self._profiling_per_request = True
+            elif self._profiling == "per_request_method":
+                self._profiling_per_request_method = True
+        if self._profiling_per_request or self._profiling_per_request_method:
+            logger.info("profiling top X functions: %d", self._profiling_top_x_functions)
+        if self._profiling_per_request:
+            logger.info("profiling per request minimum duration: %d (below are skipped)", self._profiling_per_request_min_duration)
+        if self._profiling_per_request_method:
+            logger.info("profiling per request method interval: %d seconds", self._profiling_per_request_method_interval)
+        # Profiling per request method initialization
+        if self._profiling_per_request_method:
+            for method in REQUEST_METHODS:
+                self.profiler_per_request_method[method] = cProfile.Profile()
+                self.profiler_per_request_method_counter[method] = False
+        self.profiler_per_request_method_starttime = datetime.datetime.now()
+        self.profiler_per_request_method_logtime = self.profiler_per_request_method_starttime
+
+    def __del__(self) -> None:
+        """Shutdown application."""
+        if self._profiling_per_request_method:
+            # Profiling since startup
+            self._profiler_per_request_method(True)
+
+    def _profiler_per_request_method(self, shutdown: bool = False) -> None:
+        """Display profiler data per method."""
+        profiler_timedelta_start = (datetime.datetime.now() - self.profiler_per_request_method_starttime).total_seconds()
+        for method in REQUEST_METHODS:
+            if self.profiler_per_request_method_counter[method] > 0:
+                s = io.StringIO()
+                stats = pstats.Stats(self.profiler_per_request_method[method], stream=s).sort_stats('cumulative')
+                stats.print_stats(self._profiling_top_x_functions)  # Print top X functions
+                logger.info("Profiling data per request method %s after %d seconds and %d requests: %s", method, profiler_timedelta_start, self.profiler_per_request_method_counter[method], s.getvalue())
+            else:
+                if shutdown:
+                    logger.info("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start)
+                else:
+                    logger.debug("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start)
 
 
     def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron:
     def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron:
         """Mask passwords and cookies."""
         """Mask passwords and cookies."""
@@ -156,6 +212,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
         request_method = environ["REQUEST_METHOD"].upper()
         request_method = environ["REQUEST_METHOD"].upper()
         unsafe_path = environ.get("PATH_INFO", "")
         unsafe_path = environ.get("PATH_INFO", "")
         https = environ.get("HTTPS", "")
         https = environ.get("HTTPS", "")
+        profiler = None
 
 
         context = AuthContext()
         context = AuthContext()
 
 
@@ -194,6 +251,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
 
 
             # Start response
             # Start response
             time_end = datetime.datetime.now()
             time_end = datetime.datetime.now()
+            time_delta_seconds = (time_end - time_begin).total_seconds()
             status_text = "%d %s" % (
             status_text = "%d %s" % (
                 status, client.responses.get(status, "Unknown"))
                 status, client.responses.get(status, "Unknown"))
             if answer is not None:
             if answer is not None:
@@ -203,7 +261,29 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
             else:
             else:
                 logger.info("%s response status for %r%s in %.3f seconds: %s",
                 logger.info("%s response status for %r%s in %.3f seconds: %s",
                             request_method, unsafe_path, depthinfo,
                             request_method, unsafe_path, depthinfo,
-                            (time_end - time_begin).total_seconds(), status_text)
+                            time_delta_seconds, status_text)
+
+            # Profiling end
+            if self._profiling_per_request:
+                if profiler is not None:
+                    # Profiling per request
+                    if time_delta_seconds < self._profiling_per_request_min_duration:
+                        logger.debug("Profiling data per request %s for %r%s: (suppressed because duration below minimum %.3f < %.3f)", request_method, unsafe_path, depthinfo, time_delta_seconds, self._profiling_per_request_min_duration)
+                    else:
+                        s = io.StringIO()
+                        stats = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
+                        stats.print_stats(self._profiling_top_x_functions)  # Print top X functions
+                        logger.info("Profiling data per request %s for %r%s: %s", request_method, unsafe_path, depthinfo, s.getvalue())
+                else:
+                    logger.debug("Profiling data per request %s for %r%s: (suppressed because of no data)", request_method, unsafe_path, depthinfo)
+            elif self._profiling_per_request_method:
+                self.profiler_per_request_method[request_method].disable()
+                self.profiler_per_request_method_counter[request_method] += 1
+                profiler_timedelta = (datetime.datetime.now() - self.profiler_per_request_method_logtime).total_seconds()
+                if profiler_timedelta > self._profiling_per_request_method_interval:
+                    self._profiler_per_request_method()
+                    self.profiler_per_request_method_logtime = datetime.datetime.now()
+
             # Return response content
             # Return response content
             return status_text, list(headers.items()), answers
             return status_text, list(headers.items()), answers
 
 
@@ -370,8 +450,23 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
                     return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
                     return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
 
 
         if not login or user:
         if not login or user:
+            # Profiling
+            if self._profiling_per_request:
+                profiler = cProfile.Profile()
+                profiler.enable()
+            elif self._profiling_per_request_method:
+                self.profiler_per_request_method[request_method].enable()
+
             status, headers, answer = function(
             status, headers, answer = function(
                 environ, base_prefix, path, user, remote_host, remote_useragent)
                 environ, base_prefix, path, user, remote_host, remote_useragent)
+
+            # Profiling
+            if self._profiling_per_request:
+                if profiler is not None:
+                    profiler.disable()
+            elif self._profiling_per_request_method:
+                self.profiler_per_request_method[request_method].disable()
+
             if (status, headers, answer) == httputils.NOT_ALLOWED:
             if (status, headers, answer) == httputils.NOT_ALLOWED:
                 logger.info("Access to %r denied for %s", path,
                 logger.info("Access to %r denied for %s", path,
                             repr(user) if user else "anonymous user")
                             repr(user) if user else "anonymous user")

+ 24 - 0
radicale/config.py

@@ -45,6 +45,8 @@ DEFAULT_CONFIG_PATH: str = os.pathsep.join([
     "?/etc/radicale/config",
     "?/etc/radicale/config",
     "?~/.config/radicale/config"])
     "?~/.config/radicale/config"])
 
 
+PROFILING: Sequence[str] = ("per_request", "per_request_method", "none")
+
 
 
 def positive_int(value: Any) -> int:
 def positive_int(value: Any) -> int:
     value = int(value)
     value = int(value)
@@ -70,6 +72,12 @@ def logging_level(value: Any) -> str:
     return value
     return value
 
 
 
 
+def profiling(value: Any) -> str:
+    if value not in PROFILING:
+        raise ValueError("unsupported profiling: %r" % value)
+    return value
+
+
 def filepath(value: Any) -> str:
 def filepath(value: Any) -> str:
     if not value:
     if not value:
         return ""
         return ""
@@ -581,6 +589,22 @@ This is an automated message. Please do not reply.""",
             "value": "False",
             "value": "False",
             "help": "log storage cache action on level=debug",
             "help": "log storage cache action on level=debug",
             "type": bool}),
             "type": bool}),
+        ("profiling", {
+            "value": "none",
+            "help": "log profiling data level=info",
+            "type": profiling}),
+        ("profiling_per_request_min_duration", {
+            "value": "3",
+            "help": "log profiling data per request minimum duration (seconds)",
+            "type": int}),
+        ("profiling_per_request_method_interval", {
+            "value": "600",
+            "help": "log profiling data per request method interval (seconds)",
+            "type": int}),
+        ("profiling_top_x_functions", {
+            "value": "10",
+            "help": "log profiling top X functions (limit)",
+            "type": int}),
         ("mask_passwords", {
         ("mask_passwords", {
             "value": "True",
             "value": "True",
             "help": "mask passwords in logs",
             "help": "mask passwords in logs",