Merge pull request #1935 from pbiering/improve-profiling

Improve profiling and logging
This commit is contained in:
Peter Bieringer
2025-12-10 18:46:03 +01:00
committed by GitHub
22 changed files with 190 additions and 74 deletions

View File

@@ -6,6 +6,8 @@
* 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 * Extend: [logging] with profiling log per reqest or regular per request method
* New: [logging] option to log response header on debug loglevel
* Adjust: [logging] header/content debug log indended by space to be skipped by logwatch
## 3.5.9 ## 3.5.9
* Extend: [auth] add support for type http_remote_user * Extend: [auth] add support for type http_remote_user

View File

@@ -1654,7 +1654,7 @@ Default: `False`
_(>= 3.2.2)_ _(>= 3.2.2)_
Log request on `level = debug` Log request header on `level = debug`
Default: `False` Default: `False`
@@ -1662,7 +1662,15 @@ Default: `False`
_(>= 3.2.2)_ _(>= 3.2.2)_
Log request on `level = debug` Log request content (body) on `level = debug`
Default: `False`
##### response_header_on_debug
_(>= 3.5.10)_
Log response header on `level = debug`
Default: `False` Default: `False`
@@ -1670,7 +1678,7 @@ Default: `False`
_(>= 3.2.2)_ _(>= 3.2.2)_
Log response on `level = debug` Log response content (body) on `level = debug`
Default: `False` Default: `False`
@@ -1711,6 +1719,22 @@ Log profiling data per request minimum duration (seconds) before logging, otherw
Default: `3` Default: `3`
##### profiling_per_request_header
_(>= 3.5.10)_
Log profiling request header (if passing minimum duration)
Default: `False`
##### profiling_per_request_xml
_(>= 3.5.10)_
Log profiling request XML (if passing minimum duration)
Default: `False`
##### profiling_per_request_method_interval ##### profiling_per_request_method_interval
_(>= 3.5.10)_ _(>= 3.5.10)_

11
config
View File

@@ -312,6 +312,9 @@
# Log request content on level=debug # Log request content on level=debug
#request_content_on_debug = False #request_content_on_debug = False
# Log response header on level=debug
#response_header_on_debug = False
# Log response content on level=debug # Log response content on level=debug
#response_content_on_debug = False #response_content_on_debug = False
@@ -325,9 +328,15 @@
# Value: per_request | per_request_method | none # Value: per_request | per_request_method | none
#profiling = none #profiling = none
# Log profiling data per request minium duration (seconds) # Log profiling data per request minimum duration (seconds)
#profiling_per_request_min_duration = 3 #profiling_per_request_min_duration = 3
# Log profiling request header (if passing minimum duration)
#profiling_per_request_header = False
# Log profiling request XML (if passing minimum duration)
#profiling_per_request_xml = False
# Log profiling data per request method interval (seconds) # Log profiling data per request method interval (seconds)
#profiling_per_request_method_interval = 600 #profiling_per_request_method_interval = 600

View File

@@ -38,7 +38,7 @@ import zlib
from http import client from http import client
from typing import Iterable, List, Mapping, Tuple, Union from typing import Iterable, List, Mapping, Tuple, Union
from radicale import config, httputils, log, pathutils, types from radicale import config, httputils, log, pathutils, types, utils
from radicale.app.base import ApplicationBase from radicale.app.base import ApplicationBase
from radicale.app.delete import ApplicationPartDelete from radicale.app.delete import ApplicationPartDelete
from radicale.app.get import ApplicationPartGet from radicale.app.get import ApplicationPartGet
@@ -96,8 +96,15 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
super().__init__(configuration) super().__init__(configuration)
self._mask_passwords = configuration.get("logging", "mask_passwords") self._mask_passwords = configuration.get("logging", "mask_passwords")
self._bad_put_request_content = configuration.get("logging", "bad_put_request_content") self._bad_put_request_content = configuration.get("logging", "bad_put_request_content")
logger.info("log bad put request content: %s", self._bad_put_request_content)
self._request_header_on_debug = configuration.get("logging", "request_header_on_debug") self._request_header_on_debug = configuration.get("logging", "request_header_on_debug")
self._request_content_on_debug = configuration.get("logging", "request_content_on_debug")
self._response_header_on_debug = configuration.get("logging", "response_header_on_debug")
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
logger.debug("log request header on debug: %s", self._request_header_on_debug)
logger.debug("log request content on debug: %s", self._request_content_on_debug)
logger.debug("log response header on debug: %s", self._response_header_on_debug)
logger.debug("log response content on debug: %s", self._response_content_on_debug)
self._auth_delay = configuration.get("auth", "delay") self._auth_delay = configuration.get("auth", "delay")
self._auth_type = configuration.get("auth", "type") self._auth_type = configuration.get("auth", "type")
self._web_type = configuration.get("web", "type") self._web_type = configuration.get("web", "type")
@@ -130,6 +137,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
# Profiling options # Profiling options
self._profiling = configuration.get("logging", "profiling") self._profiling = configuration.get("logging", "profiling")
self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration") self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration")
self._profiling_per_request_header = configuration.get("logging", "profiling_per_request_header")
self._profiling_per_request_xml = configuration.get("logging", "profiling_per_request_xml")
self._profiling_per_request_method_interval = configuration.get("logging", "profiling_per_request_method_interval") 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") self._profiling_top_x_functions = configuration.get("logging", "profiling_top_x_functions")
if self._profiling in config.PROFILING: if self._profiling in config.PROFILING:
@@ -142,6 +151,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
logger.info("profiling top X functions: %d", self._profiling_top_x_functions) logger.info("profiling top X functions: %d", self._profiling_top_x_functions)
if self._profiling_per_request: if self._profiling_per_request:
logger.info("profiling per request minimum duration: %d (below are skipped)", self._profiling_per_request_min_duration) logger.info("profiling per request minimum duration: %d (below are skipped)", self._profiling_per_request_min_duration)
logger.info("profiling per request header: %s", self._profiling_per_request_header)
logger.info("profiling per request xml : %s", self._profiling_per_request_xml)
if self._profiling_per_request_method: if self._profiling_per_request_method:
logger.info("profiling per request method interval: %d seconds", self._profiling_per_request_method_interval) logger.info("profiling per request method interval: %d seconds", self._profiling_per_request_method_interval)
# Profiling per request method initialization # Profiling per request method initialization
@@ -164,9 +175,11 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
for method in REQUEST_METHODS: for method in REQUEST_METHODS:
if self.profiler_per_request_method_counter[method] > 0: if self.profiler_per_request_method_counter[method] > 0:
s = io.StringIO() s = io.StringIO()
s.write("**Profiling statistics BEGIN**\n")
stats = pstats.Stats(self.profiler_per_request_method[method], stream=s).sort_stats('cumulative') 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 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()) s.write("**Profiling statistics END**\n")
logger.info("Profiling data per request method %s after %d seconds and %d requests:\n%s", method, profiler_timedelta_start, self.profiler_per_request_method_counter[method], utils.textwrap_str(s.getvalue(), -1))
else: else:
if shutdown: if shutdown:
logger.info("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start) logger.info("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start)
@@ -193,7 +206,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
"%s", environ.get("REQUEST_METHOD", "unknown"), "%s", environ.get("REQUEST_METHOD", "unknown"),
environ.get("PATH_INFO", ""), e, exc_info=True) environ.get("PATH_INFO", ""), e, exc_info=True)
# Make minimal response # Make minimal response
status, raw_headers, raw_answer = ( status, raw_headers, raw_answer, xml_request = (
httputils.INTERNAL_SERVER_ERROR) httputils.INTERNAL_SERVER_ERROR)
assert isinstance(raw_answer, str) assert isinstance(raw_answer, str)
answer = raw_answer.encode("ascii") answer = raw_answer.encode("ascii")
@@ -213,12 +226,15 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
unsafe_path = environ.get("PATH_INFO", "") unsafe_path = environ.get("PATH_INFO", "")
https = environ.get("HTTPS", "") https = environ.get("HTTPS", "")
profiler = None profiler = None
profiler_active = False
xml_request = None
context = AuthContext() context = AuthContext()
"""Manage a request.""" """Manage a request."""
def response(status: int, headers: types.WSGIResponseHeaders, def response(status: int, headers: types.WSGIResponseHeaders,
answer: Union[None, str, bytes]) -> _IntermediateResponse: answer: Union[None, str, bytes],
xml_request: Union[None, str] = None) -> _IntermediateResponse:
"""Helper to create response from internal types.WSGIResponse""" """Helper to create response from internal types.WSGIResponse"""
headers = dict(headers) headers = dict(headers)
content_encoding = "plain" content_encoding = "plain"
@@ -227,7 +243,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
if answer is not None: if answer is not None:
if isinstance(answer, str): if isinstance(answer, str):
if self._response_content_on_debug: if self._response_content_on_debug:
logger.debug("Response content:\n%s", answer) logger.debug("Response content (nonXML):\n%s", utils.textwrap_str(answer))
else: else:
logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug") logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
headers["Content-Type"] += "; charset=%s" % self._encoding headers["Content-Type"] += "; charset=%s" % self._encoding
@@ -249,6 +265,11 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
# Add extra headers set in configuration # Add extra headers set in configuration
headers.update(self._extra_headers) headers.update(self._extra_headers)
if self._response_header_on_debug:
logger.debug("Response header:\n%s", utils.textwrap_str(pprint.pformat(headers)))
else:
logger.debug("Response header: suppressed by config/option [logging] response_header_on_debug")
# Start response # Start response
time_end = datetime.datetime.now() time_end = datetime.datetime.now()
time_delta_seconds = (time_end - time_begin).total_seconds() time_delta_seconds = (time_end - time_begin).total_seconds()
@@ -265,17 +286,32 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
# Profiling end # Profiling end
if self._profiling_per_request: if self._profiling_per_request:
if profiler is not None: if profiler_active is True:
# Profiling per request if profiler is not None:
if time_delta_seconds < self._profiling_per_request_min_duration: # Profiling per request
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) 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()
s.write("**Profiling statistics BEGIN**\n")
stats = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
stats.print_stats(self._profiling_top_x_functions) # Print top X functions
s.write("**Profiling statistics END**\n")
if self._profiling_per_request_header:
s.write("**Profiling request header BEGIN**\n")
s.write(pprint.pformat(self._scrub_headers(environ)))
s.write("\n**Profiling request header END**")
if self._profiling_per_request_xml:
if xml_request is not None:
s.write("\n**Profiling request content (XML) BEGIN**\n")
if xml_request is not None:
s.write(xml_request)
s.write("**Profiling request content (XML) END**")
logger.info("Profiling data per request %s for %r%s:\n%s", request_method, unsafe_path, depthinfo, utils.textwrap_str(s.getvalue(), -1))
else: else:
s = io.StringIO() logger.debug("Profiling data per request %s for %r%s: (suppressed because of no data)", request_method, unsafe_path, depthinfo)
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: else:
logger.debug("Profiling data per request %s for %r%s: (suppressed because of no data)", request_method, unsafe_path, depthinfo) logger.info("Profiling data per request %s for %r%s: (not available because of concurrent running profiling request)", request_method, unsafe_path, depthinfo)
elif self._profiling_per_request_method: elif self._profiling_per_request_method:
self.profiler_per_request_method[request_method].disable() self.profiler_per_request_method[request_method].disable()
self.profiler_per_request_method_counter[request_method] += 1 self.profiler_per_request_method_counter[request_method] += 1
@@ -318,7 +354,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
remote_host, remote_useragent, https_info) remote_host, remote_useragent, https_info)
if self._request_header_on_debug: if self._request_header_on_debug:
logger.debug("Request header:\n%s", logger.debug("Request header:\n%s",
pprint.pformat(self._scrub_headers(environ))) utils.textwrap_str(pprint.pformat(self._scrub_headers(environ))))
else: else:
logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug") logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug")
@@ -453,27 +489,39 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
# Profiling # Profiling
if self._profiling_per_request: if self._profiling_per_request:
profiler = cProfile.Profile() profiler = cProfile.Profile()
profiler.enable() try:
profiler.enable()
except ValueError:
profiler_active = False
else:
profiler_active = True
elif self._profiling_per_request_method: elif self._profiling_per_request_method:
self.profiler_per_request_method[request_method].enable() try:
self.profiler_per_request_method[request_method].enable()
except ValueError:
profiler_active = False
else:
profiler_active = True
status, headers, answer = function( status, headers, answer, xml_request = function(
environ, base_prefix, path, user, remote_host, remote_useragent) environ, base_prefix, path, user, remote_host, remote_useragent)
# Profiling # Profiling
if self._profiling_per_request: if self._profiling_per_request:
if profiler is not None: if profiler is not None:
profiler.disable() if profiler_active is True:
profiler.disable()
elif self._profiling_per_request_method: elif self._profiling_per_request_method:
self.profiler_per_request_method[request_method].disable() if profiler_active is True:
self.profiler_per_request_method[request_method].disable()
if (status, headers, answer) == httputils.NOT_ALLOWED: if (status, headers, answer, xml_request) == 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")
else: else:
status, headers, answer = httputils.NOT_ALLOWED status, headers, answer, xml_request = httputils.NOT_ALLOWED
if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and if ((status, headers, answer, xml_request) == httputils.NOT_ALLOWED and not user and
not external_login): not external_login):
# Unknown or unauthorized user # Unknown or unauthorized user
logger.debug("Asking client for authentication") logger.debug("Asking client for authentication")
@@ -483,4 +531,4 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
"WWW-Authenticate": "WWW-Authenticate":
"Basic realm=\"%s\"" % self._auth_realm}) "Basic realm=\"%s\"" % self._auth_realm})
return response(status, headers, answer) return response(status, headers, answer, xml_request)

View File

@@ -23,7 +23,7 @@ import xml.etree.ElementTree as ET
from typing import Optional from typing import Optional
from radicale import (auth, config, hook, httputils, pathutils, rights, from radicale import (auth, config, hook, httputils, pathutils, rights,
storage, types, web, xmlutils) storage, types, utils, web, xmlutils)
from radicale.log import logger from radicale.log import logger
# HACK: https://github.com/tiran/defusedxml/issues/54 # HACK: https://github.com/tiran/defusedxml/issues/54
@@ -71,7 +71,7 @@ class ApplicationBase:
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
if self._request_content_on_debug: if self._request_content_on_debug:
logger.debug("Request content (XML):\n%s", logger.debug("Request content (XML):\n%s",
xmlutils.pretty_xml(xml_content)) utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
else: else:
logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug") logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug")
return xml_content return xml_content
@@ -80,7 +80,7 @@ class ApplicationBase:
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
if self._response_content_on_debug: if self._response_content_on_debug:
logger.debug("Response content (XML):\n%s", logger.debug("Response content (XML):\n%s",
xmlutils.pretty_xml(xml_content)) utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
else: else:
logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug") logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug")
f = io.BytesIO() f = io.BytesIO()
@@ -93,7 +93,7 @@ class ApplicationBase:
"""Generate XML error response.""" """Generate XML error response."""
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
content = self._xml_response(xmlutils.webdav_error(human_tag)) content = self._xml_response(xmlutils.webdav_error(human_tag))
return status, headers, content return status, headers, content, None
class Access: class Access:

View File

@@ -110,4 +110,4 @@ class ApplicationPartDelete(ApplicationBase):
for notification_item in hook_notification_item_list: for notification_item in hook_notification_item_list:
self._hook.notify(notification_item) self._hook.notify(notification_item)
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
return client.OK, headers, self._xml_response(xml_answer) return client.OK, headers, self._xml_response(xml_answer), None

View File

@@ -109,4 +109,4 @@ class ApplicationPartGet(ApplicationBase):
if content_disposition: if content_disposition:
headers["Content-Disposition"] = content_disposition headers["Content-Disposition"] = content_disposition
answer = item.serialize() answer = item.serialize()
return client.OK, headers, answer return client.OK, headers, answer, None

View File

@@ -89,4 +89,4 @@ class ApplicationPartMkcalendar(ApplicationBase):
logger.warning( logger.warning(
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
return client.CREATED, {}, None return client.CREATED, {}, None, xmlutils.pretty_xml(xml_content)

View File

@@ -94,4 +94,4 @@ class ApplicationPartMkcol(ApplicationBase):
"Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True) "Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True)
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
logger.info("MKCOL request %r (type:%s): %s", path, collection_type, "successful") logger.info("MKCOL request %r (type:%s): %s", path, collection_type, "successful")
return client.CREATED, {}, None return client.CREATED, {}, None, xmlutils.pretty_xml(xml_content)

View File

@@ -127,4 +127,4 @@ class ApplicationPartMove(ApplicationBase):
logger.warning( logger.warning(
"Bad MOVE request on %r: %s", path, e, exc_info=True) "Bad MOVE request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
return client.NO_CONTENT if to_item else client.CREATED, {}, None return client.NO_CONTENT if to_item else client.CREATED, {}, None, None

View File

@@ -33,4 +33,4 @@ class ApplicationPartOptions(ApplicationBase):
"Allow": ", ".join( "Allow": ", ".join(
name[3:] for name in dir(self) if name.startswith("do_")), name[3:] for name in dir(self) if name.startswith("do_")),
"DAV": httputils.DAV_HEADERS} "DAV": httputils.DAV_HEADERS}
return client.OK, headers, None return client.OK, headers, None, None

View File

@@ -410,4 +410,4 @@ class ApplicationPartPropfind(ApplicationBase):
allowed_items, user, self._encoding) allowed_items, user, self._encoding)
if xml_answer is None: if xml_answer is None:
return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED
return client.MULTI_STATUS, headers, self._xml_response(xml_answer) return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)

View File

@@ -131,4 +131,4 @@ class ApplicationPartProppatch(ApplicationBase):
logger.warning( logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True) "Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
return client.MULTI_STATUS, headers, self._xml_response(xml_answer) return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)

View File

@@ -99,7 +99,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str,
content = vobject_item content = vobject_item
else: else:
content = item._text content = item._text
logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, content) logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, utils.textwrap_str(content))
else: else:
logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e) logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
raise raise
@@ -117,7 +117,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str,
content = vobject_item content = vobject_item
else: else:
content = item._text content = item._text
logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, content) logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, utils.textwrap_str(content))
else: else:
logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e) logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
raise raise
@@ -180,7 +180,7 @@ class ApplicationPartPut(ApplicationBase):
logger.warning( logger.warning(
"Bad PUT request on %r (read_components): %s", path, e, exc_info=True) "Bad PUT request on %r (read_components): %s", path, e, exc_info=True)
if self._log_bad_put_request_content: if self._log_bad_put_request_content:
logger.warning("Bad PUT request content of %r:\n%s", path, content) logger.warning("Bad PUT request content of %r:\n%s", path, utils.textwrap_str(content))
else: else:
logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content") logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
@@ -334,7 +334,7 @@ class ApplicationPartPut(ApplicationBase):
if (item and item.uid == prepared_item.uid): if (item and item.uid == prepared_item.uid):
logger.debug("PUT request updated existing item %r", path) logger.debug("PUT request updated existing item %r", path)
headers = {"ETag": etag} headers = {"ETag": etag}
return client.NO_CONTENT, headers, None return client.NO_CONTENT, headers, None, None
headers = {"ETag": etag} headers = {"ETag": etag}
return client.CREATED, headers, None return client.CREATED, headers, None, None

View File

@@ -815,7 +815,7 @@ class ApplicationPartReport(ApplicationBase):
"Bad REPORT request on %r: %s", path, e, exc_info=True) "Bad REPORT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding} headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
return status, headers, str(body) return status, headers, str(body), xmlutils.pretty_xml(xml_content)
else: else:
try: try:
status, xml_answer = xml_report( status, xml_answer = xml_report(
@@ -826,4 +826,4 @@ class ApplicationPartReport(ApplicationBase):
"Bad REPORT request on %r: %s", path, e, exc_info=True) "Bad REPORT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
return status, headers, self._xml_response(xml_answer) return status, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)

View File

@@ -577,6 +577,10 @@ This is an automated message. Please do not reply.""",
"value": "False", "value": "False",
"help": "log request content on level=debug", "help": "log request content on level=debug",
"type": bool}), "type": bool}),
("response_header_on_debug", {
"value": "False",
"help": "log response header on level=debug",
"type": bool}),
("response_content_on_debug", { ("response_content_on_debug", {
"value": "False", "value": "False",
"help": "log response content on level=debug", "help": "log response content on level=debug",
@@ -597,6 +601,14 @@ This is an automated message. Please do not reply.""",
"value": "3", "value": "3",
"help": "log profiling data per request minimum duration (seconds)", "help": "log profiling data per request minimum duration (seconds)",
"type": int}), "type": int}),
("profiling_per_request_header", {
"value": "False",
"help": "Log profiling request body (if passing minimum duration)",
"type": bool}),
("profiling_per_request_xml", {
"value": "False",
"help": "Log profiling request XML (if passing minimum duration)",
"type": bool}),
("profiling_per_request_method_interval", { ("profiling_per_request_method_interval", {
"value": "600", "value": "600",
"help": "log profiling data per request method interval (seconds)", "help": "log profiling data per request method interval (seconds)",

View File

@@ -31,7 +31,7 @@ import time
from http import client from http import client
from typing import List, Mapping, Union, cast from typing import List, Mapping, Union, cast
from radicale import config, pathutils, types from radicale import config, pathutils, types, utils
from radicale.log import logger from radicale.log import logger
if sys.version_info < (3, 9): if sys.version_info < (3, 9):
@@ -49,42 +49,42 @@ else:
NOT_ALLOWED: types.WSGIResponse = ( NOT_ALLOWED: types.WSGIResponse = (
client.FORBIDDEN, (("Content-Type", "text/plain"),), client.FORBIDDEN, (("Content-Type", "text/plain"),),
"Access to the requested resource forbidden.") "Access to the requested resource forbidden.", None)
FORBIDDEN: types.WSGIResponse = ( FORBIDDEN: types.WSGIResponse = (
client.FORBIDDEN, (("Content-Type", "text/plain"),), client.FORBIDDEN, (("Content-Type", "text/plain"),),
"Action on the requested resource refused.") "Action on the requested resource refused.", None)
BAD_REQUEST: types.WSGIResponse = ( BAD_REQUEST: types.WSGIResponse = (
client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request") client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request", None)
NOT_FOUND: types.WSGIResponse = ( NOT_FOUND: types.WSGIResponse = (
client.NOT_FOUND, (("Content-Type", "text/plain"),), client.NOT_FOUND, (("Content-Type", "text/plain"),),
"The requested resource could not be found.") "The requested resource could not be found.", None)
CONFLICT: types.WSGIResponse = ( CONFLICT: types.WSGIResponse = (
client.CONFLICT, (("Content-Type", "text/plain"),), client.CONFLICT, (("Content-Type", "text/plain"),),
"Conflict in the request.") "Conflict in the request.", None)
METHOD_NOT_ALLOWED: types.WSGIResponse = ( METHOD_NOT_ALLOWED: types.WSGIResponse = (
client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),), client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),),
"The method is not allowed on the requested resource.") "The method is not allowed on the requested resource.", None)
PRECONDITION_FAILED: types.WSGIResponse = ( PRECONDITION_FAILED: types.WSGIResponse = (
client.PRECONDITION_FAILED, client.PRECONDITION_FAILED,
(("Content-Type", "text/plain"),), "Precondition failed.") (("Content-Type", "text/plain"),), "Precondition failed.", None)
REQUEST_TIMEOUT: types.WSGIResponse = ( REQUEST_TIMEOUT: types.WSGIResponse = (
client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),), client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),),
"Connection timed out.") "Connection timed out.", None)
REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = ( REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = (
client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),), client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),),
"Request body too large.") "Request body too large.", None)
REMOTE_DESTINATION: types.WSGIResponse = ( REMOTE_DESTINATION: types.WSGIResponse = (
client.BAD_GATEWAY, (("Content-Type", "text/plain"),), client.BAD_GATEWAY, (("Content-Type", "text/plain"),),
"Remote destination not supported.") "Remote destination not supported.", None)
DIRECTORY_LISTING: types.WSGIResponse = ( DIRECTORY_LISTING: types.WSGIResponse = (
client.FORBIDDEN, (("Content-Type", "text/plain"),), client.FORBIDDEN, (("Content-Type", "text/plain"),),
"Directory listings are not supported.") "Directory listings are not supported.", None)
INSUFFICIENT_STORAGE: types.WSGIResponse = ( INSUFFICIENT_STORAGE: types.WSGIResponse = (
client.INSUFFICIENT_STORAGE, (("Content-Type", "text/plain"),), client.INSUFFICIENT_STORAGE, (("Content-Type", "text/plain"),),
"Insufficient Storage. Please contact the administrator.") "Insufficient Storage. Please contact the administrator.", None)
INTERNAL_SERVER_ERROR: types.WSGIResponse = ( INTERNAL_SERVER_ERROR: types.WSGIResponse = (
client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),), client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),),
"A server error occurred. Please contact the administrator.") "A server error occurred. Please contact the administrator.", None)
DAV_HEADERS: str = "1, 2, 3, calendar-access, addressbook, extended-mkcol" DAV_HEADERS: str = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
@@ -150,7 +150,7 @@ def read_request_body(configuration: "config.Configuration",
content = decode_request(configuration, environ, content = decode_request(configuration, environ,
read_raw_request_body(configuration, environ)) read_raw_request_body(configuration, environ))
if configuration.get("logging", "request_content_on_debug"): if configuration.get("logging", "request_content_on_debug"):
logger.debug("Request content:\n%s", content) logger.debug("Request content:\n%s", utils.textwrap_str(content))
else: else:
logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug") logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug")
return content return content
@@ -159,7 +159,7 @@ def read_request_body(configuration: "config.Configuration",
def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse: def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse:
return (status, return (status,
{"Location": location, "Content-Type": "text/plain"}, {"Location": location, "Content-Type": "text/plain"},
"Redirected to %s" % location) "Redirected to %s" % location, None)
def _serve_traversable( def _serve_traversable(
@@ -214,7 +214,7 @@ def _serve_traversable(
# adjust on the fly default main.js of InfCloud installation # adjust on the fly default main.js of InfCloud installation
logger.debug("Adjust on-the-fly default InfCloud main.js in served page: %r", path) logger.debug("Adjust on-the-fly default InfCloud main.js in served page: %r", path)
answer = answer.replace(b"'InfCloud - the open source CalDAV/CardDAV web client'", b"'InfCloud - the open source CalDAV/CardDAV web client - served through Radicale CalDAV/CardDAV server'") answer = answer.replace(b"'InfCloud - the open source CalDAV/CardDAV web client'", b"'InfCloud - the open source CalDAV/CardDAV web client - served through Radicale CalDAV/CardDAV server'")
return client.OK, headers, answer return client.OK, headers, answer, None
def serve_resource( def serve_resource(

View File

@@ -1,5 +1,6 @@
# This file is part of Radicale - CalDAV and CardDAV server # This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2017-2018 Unrud <unrud@outlook.com> # Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
# #
# This library is free software: you can redistribute it and/or modify # This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
@@ -28,9 +29,9 @@ class Web(web.BaseWeb):
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse: user: str) -> types.WSGIResponse:
return client.OK, {"Content-Type": "text/plain"}, "custom" return client.OK, {"Content-Type": "text/plain"}, "custom", None
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse: user: str) -> types.WSGIResponse:
content = httputils.read_request_body(self.configuration, environ) content = httputils.read_request_body(self.configuration, environ)
return client.OK, {"Content-Type": "text/plain"}, "echo:" + content return client.OK, {"Content-Type": "text/plain"}, "echo:" + content, None

View File

@@ -1,5 +1,6 @@
# This file is part of Radicale - CalDAV and CardDAV server # This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2020 Unrud <unrud@outlook.com> # Copyright © 2020-2023 Unrud <unrud@outlook.com>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
# #
# This library is free software: you can redistribute it and/or modify # This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
@@ -20,7 +21,7 @@ from typing import (Any, Callable, ContextManager, Iterator, List, Mapping,
runtime_checkable) runtime_checkable)
WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]] WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]]
WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes]] WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes], Union[None, str]]
WSGIEnviron = Mapping[str, Any] WSGIEnviron = Mapping[str, Any]
WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any] WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any]

View File

@@ -21,6 +21,7 @@ import datetime
import os import os
import ssl import ssl
import sys import sys
import textwrap
from importlib import import_module, metadata from importlib import import_module, metadata
from typing import Callable, Sequence, Tuple, Type, TypeVar, Union from typing import Callable, Sequence, Tuple, Type, TypeVar, Union
@@ -291,3 +292,16 @@ def format_ut(unixtime: int) -> str:
dt = datetime.datetime.fromtimestamp(unixtime, datetime.UTC) dt = datetime.datetime.fromtimestamp(unixtime, datetime.UTC)
r = str(unixtime) + "(" + dt.strftime('%Y-%m-%dT%H:%M:%SZ') + ")" r = str(unixtime) + "(" + dt.strftime('%Y-%m-%dT%H:%M:%SZ') + ")"
return r return r
def limit_str(content: str, limit: int) -> str:
length = len(content)
if limit > 0 and length >= limit:
return content[:limit] + ("...(shortened because original length %d > limit %d)" % (length, limit))
else:
return content
def textwrap_str(content: str, limit: int = 2000) -> str:
# TODO: add support for config option and prefix
return textwrap.indent(limit_str(content, limit), " ", lambda line: True)

View File

@@ -1,5 +1,6 @@
# This file is part of Radicale - CalDAV and CardDAV server # This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2017-2018 Unrud <unrud@outlook.com> # Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
# #
# This library is free software: you can redistribute it and/or modify # This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
@@ -32,4 +33,4 @@ class Web(web.BaseWeb):
assert pathutils.sanitize_path(path) == path assert pathutils.sanitize_path(path) == path
if path != "/.web": if path != "/.web":
return httputils.redirect(base_prefix + "/.web") return httputils.redirect(base_prefix + "/.web")
return client.OK, {"Content-Type": "text/plain"}, "Radicale works!" return client.OK, {"Content-Type": "text/plain"}, "Radicale works!", None

View File

@@ -2,7 +2,8 @@
# Copyright © 2008 Nicolas Kandel # Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter # Copyright © 2008 Pascal Halter
# Copyright © 2008-2015 Guillaume Ayoub # Copyright © 2008-2015 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com> # Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
# #
# This library is free software: you can redistribute it and/or modify # This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
@@ -26,7 +27,7 @@ import copy
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from collections import OrderedDict from collections import OrderedDict
from http import client from http import client
from typing import Dict, Mapping, Optional from typing import Dict, Mapping, Optional, Union
from urllib.parse import quote from urllib.parse import quote
from radicale import item, pathutils from radicale import item, pathutils
@@ -56,7 +57,7 @@ for short, url in NAMESPACES.items():
ET.register_namespace("" if short == "D" else short, url) ET.register_namespace("" if short == "D" else short, url)
def pretty_xml(element: ET.Element) -> str: def pretty_xml(element: Union[ET.Element, None]) -> str:
"""Indent an ElementTree ``element`` and its children.""" """Indent an ElementTree ``element`` and its children."""
def pretty_xml_recursive(element: ET.Element, level: int) -> None: def pretty_xml_recursive(element: ET.Element, level: int) -> None:
indent = "\n" + level * " " indent = "\n" + level * " "
@@ -71,6 +72,9 @@ def pretty_xml(element: ET.Element) -> str:
sub_element.tail = indent sub_element.tail = indent
elif level > 0 and not (element.tail or "").strip(): elif level > 0 and not (element.tail or "").strip():
element.tail = indent element.tail = indent
if element is None:
return ""
element = copy.deepcopy(element) element = copy.deepcopy(element)
pretty_xml_recursive(element, 0) pretty_xml_recursive(element, 0)
return '<?xml version="1.0"?>\n%s' % ET.tostring(element, "unicode") return '<?xml version="1.0"?>\n%s' % ET.tostring(element, "unicode")