Merge lp:~allenap/maas/make-web-access-logs-prettier into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 5897
Proposed branch: lp:~allenap/maas/make-web-access-logs-prettier
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 366 lines (+207/-6)
6 files modified
src/maasserver/tests/test_webapp.py (+5/-2)
src/maasserver/webapp.py (+5/-2)
src/provisioningserver/rackdservices/image.py (+2/-1)
src/provisioningserver/tests/test_plugin.py (+4/-0)
src/provisioningserver/utils/tests/test_twisted.py (+124/-1)
src/provisioningserver/utils/twisted.py (+67/-0)
To merge this branch: bzr merge lp:~allenap/maas/make-web-access-logs-prettier
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Review via email: mp+321495@code.launchpad.net

Commit message

Make web access log lines prettier and more readable.

Description of the change

Transforms:

  2017-03-30 23:01:26 regiond: [info] ::ffff:192.168.1.3 - - [30/Mar/2017:22:01:26 +0000] "GET /MAAS/rpc/ HTTP/1.0" 200 208 "-" "provisioningserver.rpc.clusterservice.ClusterClientService"

into:

  2017-03-30 23:01:27 regiond: [info] 192.168.1.3 GET /MAAS/rpc/ HTTP/1.0 --> 200 OK (referrer: -; agent: provisioningserver.rpc.clusterservice.ClusterClientService)

i.e. gets rid of the duplicated timestamp, reorders stuff a bit, and un-IPv6-ifies IPv4 addresses.

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

I'm on the fence about this one. It looks like our previous logging was /almost/ in the "standard" HTTP log format[1], which has some utility: our current-format might make it easy to analyze the logs with any standard HTTP log analyzer. (ok, granted, maybe after a little grep/sed trickery.)

The best part of this change, to me, is the removal of the IPv4-mapped IPv6 address. That alone makes the log so much better already.

Thoughts?

[1]:
https://en.wikipedia.org/wiki/Common_Log_Format

review: Needs Information
Revision history for this message
Gavin Panella (allenap) wrote :

There is utility in the combined log format, which is what Twisted claimed it was producing, but that's reduced because we're publishing it to a "regular" log, where a namespace and level are added, and an additional timestamp. Tools designed for combined logs can't operate on it without pre-processing. In 5 years we haven't shown an interest in analysing this stuff, and there's not much HTTP traffic anyway: the UI is mostly WebSocket traffic and that is not logged. Then, most HTTP traffic is to /MAAS/rpc/ by racks.

To keep access logs for analysis we should pipe them to their own file. t.w.server.Site would prefer to do this: pushing access logs out via the "normal" loggers is a getting-started kind of mode.

But I avoided this because it goes close to the existing can of logging worms (i.e. logging both to regiond.log and journald), because it would need fiddly packaging work (inc. permissions, rotation), and because no one is yet asking for it.

Or, we could override Site.log to publish structured logs from which we can extract, for example, request errors, and produce some kind of in-app report.

In short, here I've tried to optimise for human consumption. We're not shutting the door to other, e.g. machine readable, options later on if we find a need.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Sounds good. +1 from me.

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :

Thanks!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/maasserver/tests/test_webapp.py'
--- src/maasserver/tests/test_webapp.py 2017-03-03 17:20:13 +0000
+++ src/maasserver/tests/test_webapp.py 2017-03-30 22:13:21 +0000
@@ -23,6 +23,7 @@
23from maastesting.matchers import MockCalledOnceWith23from maastesting.matchers import MockCalledOnceWith
24from maastesting.testcase import MAASTestCase24from maastesting.testcase import MAASTestCase
25from maastesting.twisted import TwistedLoggerFixture25from maastesting.twisted import TwistedLoggerFixture
26from provisioningserver.utils.twisted import reducedWebLogFormatter
26from testtools.matchers import (27from testtools.matchers import (
27 Equals,28 Equals,
28 Is,29 Is,
@@ -205,8 +206,10 @@
205 def test__init_creates_site(self):206 def test__init_creates_site(self):
206 service = self.make_webapp()207 service = self.make_webapp()
207 self.assertThat(service.site, IsInstance(Site))208 self.assertThat(service.site, IsInstance(Site))
208 self.assertThat(209 self.assertThat(service.site, MatchesStructure(
209 service.site.requestFactory, Is(webapp.CleanPathRequest))210 requestFactory=Is(webapp.CleanPathRequest),
211 _logFormatter=Is(reducedWebLogFormatter),
212 ))
210 self.assertThat(service.websocket, IsInstance(WebSocketFactory))213 self.assertThat(service.websocket, IsInstance(WebSocketFactory))
211214
212 def test__default_site_renders_starting_page(self):215 def test__default_site_renders_starting_page(self):
213216
=== modified file 'src/maasserver/webapp.py'
--- src/maasserver/webapp.py 2017-03-03 17:07:03 +0000
+++ src/maasserver/webapp.py 2017-03-30 22:13:21 +0000
@@ -25,6 +25,7 @@
25from provisioningserver.logger import LegacyLogger25from provisioningserver.logger import LegacyLogger
26from provisioningserver.utils.twisted import (26from provisioningserver.utils.twisted import (
27 asynchronous,27 asynchronous,
28 reducedWebLogFormatter,
28 ThreadPoolLimiter,29 ThreadPoolLimiter,
29)30)
30from twisted.application.internet import StreamServerEndpointService31from twisted.application.internet import StreamServerEndpointService
@@ -184,7 +185,8 @@
184 """185 """
185186
186 def __init__(self, endpoint, listener, status_worker):187 def __init__(self, endpoint, listener, status_worker):
187 self.site = OverlaySite(StartPage())188 self.site = OverlaySite(
189 StartPage(), logFormatter=reducedWebLogFormatter)
188 self.site.requestFactory = CleanPathRequest190 self.site.requestFactory = CleanPathRequest
189 super(WebApplicationService, self).__init__(endpoint, self.site)191 super(WebApplicationService, self).__init__(endpoint, self.site)
190 self.websocket = WebSocketFactory(listener)192 self.websocket = WebSocketFactory(listener)
@@ -235,7 +237,8 @@
235 WSGIResource(reactor, self.threadpool, application))237 WSGIResource(reactor, self.threadpool, application))
236 underlay_root = Resource()238 underlay_root = Resource()
237 underlay_root.putChild(b'MAAS', underlay_maas)239 underlay_root.putChild(b'MAAS', underlay_maas)
238 underlay_site = Site(underlay_root)240 underlay_site = Site(
241 underlay_root, logFormatter=reducedWebLogFormatter)
239 underlay_site.requestFactory = CleanPathRequest242 underlay_site.requestFactory = CleanPathRequest
240243
241 # Setup the main resource as the twisted handler and the underlay244 # Setup the main resource as the twisted handler and the underlay
242245
=== modified file 'src/provisioningserver/rackdservices/image.py'
--- src/provisioningserver/rackdservices/image.py 2015-12-16 14:24:50 +0000
+++ src/provisioningserver/rackdservices/image.py 2017-03-30 22:13:21 +0000
@@ -7,6 +7,7 @@
7 "BootImageEndpointService",7 "BootImageEndpointService",
8 ]8 ]
99
10from provisioningserver.utils.twisted import reducedWebLogFormatter
10from twisted.application.internet import StreamServerEndpointService11from twisted.application.internet import StreamServerEndpointService
11from twisted.web.resource import Resource12from twisted.web.resource import Resource
12from twisted.web.server import Site13from twisted.web.server import Site
@@ -28,5 +29,5 @@
28 """29 """
29 resource = Resource()30 resource = Resource()
30 resource.putChild(b'images', File(resource_root))31 resource.putChild(b'images', File(resource_root))
31 self.site = Site(resource)32 self.site = Site(resource, logFormatter=reducedWebLogFormatter)
32 super(BootImageEndpointService, self).__init__(endpoint, self.site)33 super(BootImageEndpointService, self).__init__(endpoint, self.site)
3334
=== modified file 'src/provisioningserver/tests/test_plugin.py'
--- src/provisioningserver/tests/test_plugin.py 2017-01-28 00:51:47 +0000
+++ src/provisioningserver/tests/test_plugin.py 2017-03-30 22:13:21 +0000
@@ -46,10 +46,12 @@
46)46)
47from provisioningserver.rackdservices.tftp_offload import TFTPOffloadService47from provisioningserver.rackdservices.tftp_offload import TFTPOffloadService
48from provisioningserver.testing.config import ClusterConfigurationFixture48from provisioningserver.testing.config import ClusterConfigurationFixture
49from provisioningserver.utils.twisted import reducedWebLogFormatter
49from testtools.matchers import (50from testtools.matchers import (
50 AfterPreprocessing,51 AfterPreprocessing,
51 Contains,52 Contains,
52 Equals,53 Equals,
54 Is,
53 IsInstance,55 IsInstance,
54 KeysEqual,56 KeysEqual,
55 MatchesAll,57 MatchesAll,
@@ -206,6 +208,8 @@
206 image_service = service.getServiceNamed("image_service")208 image_service = service.getServiceNamed("image_service")
207 self.assertIsInstance(image_service, BootImageEndpointService)209 self.assertIsInstance(image_service, BootImageEndpointService)
208 self.assertIsInstance(image_service.site, Site)210 self.assertIsInstance(image_service.site, Site)
211 self.assertThat(image_service.site, MatchesStructure(
212 _logFormatter=Is(reducedWebLogFormatter)))
209 resource = image_service.site.resource213 resource = image_service.site.resource
210 root = resource.getChildWithDefault(b"images", request=None)214 root = resource.getChildWithDefault(b"images", request=None)
211 self.assertThat(root, IsInstance(FilePath))215 self.assertThat(root, IsInstance(FilePath))
212216
=== modified file 'src/provisioningserver/utils/tests/test_twisted.py'
--- src/provisioningserver/utils/tests/test_twisted.py 2017-03-24 15:28:07 +0000
+++ src/provisioningserver/utils/tests/test_twisted.py 2017-03-30 22:13:21 +0000
@@ -63,6 +63,7 @@
63 LONGTIME,63 LONGTIME,
64 makeDeferredWithProcessProtocol,64 makeDeferredWithProcessProtocol,
65 pause,65 pause,
66 reducedWebLogFormatter,
66 retries,67 retries,
67 RPCFetcher,68 RPCFetcher,
68 synchronous,69 synchronous,
@@ -71,6 +72,7 @@
71 ThreadPoolLimiter,72 ThreadPoolLimiter,
72 ThreadUnpool,73 ThreadUnpool,
73)74)
75from testscenarios import multiply_scenarios
74from testtools.content import content_from_stream76from testtools.content import content_from_stream
75from testtools.deferredruntest import assert_fails_with77from testtools.deferredruntest import assert_fails_with
76from testtools.matchers import (78from testtools.matchers import (
@@ -88,7 +90,10 @@
88 Raises,90 Raises,
89)91)
90from testtools.testcase import ExpectedException92from testtools.testcase import ExpectedException
91from twisted.internet import reactor93from twisted.internet import (
94 address,
95 reactor,
96)
92from twisted.internet.defer import (97from twisted.internet.defer import (
93 AlreadyCalledError,98 AlreadyCalledError,
94 CancelledError,99 CancelledError,
@@ -112,6 +117,7 @@
112 threadable,117 threadable,
113)118)
114from twisted.python.failure import Failure119from twisted.python.failure import Failure
120from twisted.web.test import requesthelper
115121
116122
117def return_args(*args, **kwargs):123def return_args(*args, **kwargs):
@@ -1883,3 +1889,120 @@
1883 ))1889 ))
1884 self.assertThat(1890 self.assertThat(
1885 twisted_module._os_killpg, MockNotCalled())1891 twisted_module._os_killpg, MockNotCalled())
1892
1893
1894class TestReducedWebLogFormatter(MAASTestCase):
1895 """Tests for `reducedWebLogFormatter`."""
1896
1897 ipv4_address = factory.make_ipv4_address()
1898 ipv6_address = factory.make_ipv6_address()
1899 simple_http_url = factory.make_simple_http_url()
1900 simple_uri = factory.make_absolute_path()
1901 agent_name = factory.make_name("agent")
1902 status = factory.make_status_code()
1903
1904 scenarios_methods = (
1905 ("no-method", {"method": None, "method_expected": "???"}),
1906 ("method", {"method": "GET", "method_expected": "GET"}),
1907 )
1908
1909 scenarios_clients = (
1910 ("no-client", {
1911 "client": None,
1912 "client_expected": "-",
1913 }),
1914 ("ipv4-client", {
1915 "client": ipv4_address,
1916 "client_expected": ipv4_address,
1917 }),
1918 ("ipv6-client", {
1919 "client": ipv6_address,
1920 "client_expected": ipv6_address,
1921 }),
1922 ("ipv4-mapped-client", {
1923 "client": "::ffff:" + ipv4_address,
1924 "client_expected": ipv4_address,
1925 }),
1926 )
1927
1928 scenarios_referrers = (
1929 ("no-referrer", {
1930 "referrer": None,
1931 "referrer_expected": "-",
1932 }),
1933 ("referrer", {
1934 "referrer": simple_http_url,
1935 "referrer_expected": simple_http_url,
1936 }),
1937 )
1938
1939 scenarios_agents = (
1940 ("no-agent", {
1941 "agent": None,
1942 "agent_expected": "-",
1943 }),
1944 ("agent", {
1945 "agent": agent_name,
1946 "agent_expected": agent_name,
1947 }),
1948 )
1949
1950 scenarios_uris = (
1951 ("no-uri", {
1952 "uri": None,
1953 "uri_expected": "-",
1954 }),
1955 ("uri", {
1956 "uri": simple_uri,
1957 "uri_expected": simple_uri,
1958 }),
1959 )
1960
1961 scenarios_statuses = (
1962 ("no-status", {
1963 "status": None,
1964 "status_expected": "???",
1965 }),
1966 ("status", {
1967 "status": status.value,
1968 "status_expected": "%d %s" % (status.value, status.name),
1969 }),
1970 ("status-unknown", {
1971 "status": 678,
1972 "status_expected": "678",
1973 }),
1974 )
1975
1976 scenarios_types = (
1977 ("plain", {"prep": lambda string: string}),
1978 ("bytes", {"prep": lambda string: (
1979 None if string is None else string.encode("ascii"))}),
1980 )
1981
1982 scenarios = multiply_scenarios(
1983 scenarios_methods, scenarios_clients, scenarios_referrers,
1984 scenarios_agents, scenarios_uris, scenarios_statuses, scenarios_types,
1985 )
1986
1987 def test__renders_full_request(self):
1988 request = requesthelper.DummyRequest("foo/bar")
1989 request.method = self.prep(self.method)
1990 request.client = address.IPv4Address(
1991 "TCP", self.prep(self.client), 12345)
1992 request.requestHeaders.addRawHeader(
1993 "referer", self.prep(self.referrer))
1994 request.requestHeaders.addRawHeader(
1995 "user-agent", self.prep(self.agent))
1996 request.uri = self.prep(self.uri)
1997 request.code = self.status
1998
1999 self.assertThat(
2000 reducedWebLogFormatter(sentinel.timestamp, request),
2001 Equals(
2002 "%s %s %s HTTP/1.0 --> %s (referrer: %s; agent: %s)" % (
2003 self.client_expected, self.method_expected,
2004 self.uri_expected, self.status_expected,
2005 self.referrer_expected, self.agent_expected,
2006 )
2007 ),
2008 )
18862009
=== modified file 'src/provisioningserver/utils/twisted.py'
--- src/provisioningserver/utils/twisted.py 2017-03-24 15:28:07 +0000
+++ src/provisioningserver/utils/twisted.py 2017-03-30 22:13:21 +0000
@@ -20,6 +20,7 @@
20 'LONGTIME',20 'LONGTIME',
21 'makeDeferredWithProcessProtocol',21 'makeDeferredWithProcessProtocol',
22 'pause',22 'pause',
23 'reducedWebLogFormatter',
23 'retries',24 'retries',
24 'synchronous',25 'synchronous',
25 'ThreadPool',26 'ThreadPool',
@@ -35,6 +36,7 @@
35 partial,36 partial,
36 wraps,37 wraps,
37)38)
39from http import HTTPStatus
38from itertools import (40from itertools import (
39 chain,41 chain,
40 repeat,42 repeat,
@@ -50,6 +52,10 @@
50import threading52import threading
5153
52from crochet import run_in_reactor54from crochet import run_in_reactor
55from netaddr import (
56 AddrFormatError,
57 IPAddress,
58)
53from provisioningserver.logger import LegacyLogger59from provisioningserver.logger import LegacyLogger
54from twisted.internet import reactor60from twisted.internet import reactor
55from twisted.internet.defer import (61from twisted.internet.defer import (
@@ -70,7 +76,9 @@
70)76)
71from twisted.python.failure import Failure77from twisted.python.failure import Failure
72from twisted.python.threadable import isInIOThread78from twisted.python.threadable import isInIOThread
79from twisted.web.iweb import IAccessLogFormatter
73from zope import interface80from zope import interface
81from zope.interface import provider
7482
7583
76log = LegacyLogger()84log = LegacyLogger()
@@ -1043,3 +1051,62 @@
1043 killer.cancel()1051 killer.cancel()
10441052
1045 done.addBoth(callOut, ended)1053 done.addBoth(callOut, ended)
1054
1055
1056@provider(IAccessLogFormatter)
1057def reducedWebLogFormatter(timestamp, request):
1058 """Return a reduced formatted log line for the given request.
1059
1060 The `timestamp` argument is ignored. The line returned is expected to be
1061 sent out by a logger which will add its own timestamp, so this one is
1062 superfluous.
1063
1064 :see: `IAccessLogFormatter`
1065 :see: `combinedLogFormatter`
1066 """
1067 template = (
1068 "{origin} {method} {uri} {proto} --> {status} "
1069 "(referrer: {referrer}; agent: {agent})"
1070 )
1071
1072 def field(value, default):
1073 if value is None or len(value) == 0 or value.isspace():
1074 return default
1075 elif isinstance(value, bytes):
1076 return value.decode("ascii", "replace")
1077 else:
1078 return value
1079
1080 def normaliseAddress(address):
1081 """Normalise an IP address."""
1082 try:
1083 address = IPAddress(address)
1084 except AddrFormatError:
1085 return address # Hostname?
1086 else:
1087 if address.is_ipv4_mapped():
1088 return address.ipv4()
1089 else:
1090 return address
1091
1092 def describeHttpStatus(code):
1093 try:
1094 code = HTTPStatus(code)
1095 except ValueError:
1096 if isinstance(code, int):
1097 return str(code)
1098 else:
1099 return "???"
1100 else:
1101 return "{code.value:d} {code.name}".format(code=code)
1102
1103 origin = field(request.getClientIP(), None)
1104 origin = "-" if origin is None else normaliseAddress(origin)
1105
1106 return template.format(
1107 referrer=field(request.getHeader(b"referer"), "-"),
1108 agent=field(request.getHeader(b"user-agent"), "-"),
1109 status=describeHttpStatus(request.code), origin=origin,
1110 method=field(request.method, "???"), uri=field(request.uri, "-"),
1111 proto=field(request.clientproto, "-"),
1112 )