userver: en/testsuite/logcapture.py Source File
Loading...
Searching...
No Matches
logcapture.py
1"""
2Logcapture allows to intercepts service logs on demand with context manager.
3
4It starts tcp server and read logs sent by server.
5"""
6
7import asyncio
8import collections
9import contextlib
10import enum
11import logging
12import typing
13
14from testsuite.utils import callinfo, net, traceback
15
16logger = logging.getLogger(__name__)
17
18
19class BaseError(Exception):
20 pass
21
22
24 """Incorrect usage error."""
25
26
32 pass
33
34
35class LogLevel(enum.IntEnum):
36 """
37 Represents log level as IntEnum, which supports comparison.
38
39 Available levels are: TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL, NONE
40 """
41
42 TRACE = 0
43 DEBUG = 1
44 INFO = 2
45 WARNING = 3
46 ERROR = 4
47 CRITICAL = 5
48 NONE = 6
49
50 @classmethod
51 def from_string(cls, level: str) -> 'LogLevel':
52 """Parse log level from the string."""
53 return cls[level.upper()]
54
55
57 def __init__(self, *, log_level: LogLevel) -> None:
58 self._log_level = log_level
59 self._logs: list[dict] = []
60 self._subscribers = []
61 self._closed = False
62
63 @property
64 def log_level(self):
65 return self._log_level
66
67 def is_closed(self):
68 return self._closed
69
70 def close(self):
71 self._closed = True
72
73 async def publish(self, row: dict) -> None:
74 self._logs.append(row)
75 for query, callback in self._subscribers:
76 if _match_entry(row, query):
77 await callback(**row)
78
79 def subscribe(self, query: dict, decorated):
80 self._subscribers.append((query, decorated))
81
82 def __iter__(self) -> typing.Iterator[dict]:
83 return iter(self._logs)
84
85
86class Capture:
87 def __init__(self, logs: CapturedLogs):
88 self._logs = logs
89
90 def select(self, **query) -> list[dict]:
91 """Select logs matching query.
92
93 Could only be used after capture contextmanager block.
94
95 .. code-block:: python
96
97 async with logcapture_server.capture() as capture:
98 ...
99 records = capture.select(text='Message to capture')
100 """
101 if not self._logs.is_closed():
103 'select() is only supported for closed captures\n'
104 'Please move select() after context manager body',
105 )
106 level = query.get('level')
107 if level:
108 log_level = LogLevel[level]
109 if log_level.value < self._logs.log_level.value:
111 f'Requested log level={log_level.name} is lower than service log level {self._logs.log_level.name}',
112 )
113 result = []
114 for row in self._logs:
115 if _match_entry(row, query):
116 result.append(row)
117 return result
118
119 def subscribe(self, **query):
120 """Subscribe to records matching `query`. Returns decorator function.
121 `subscribe()` may only be used within `capture()` block. Callqueue is returned.
122
123 .. code-block:: python
124
125 async with logcapture_server.capture() as capture:
126 @capture.subscribe(text='Message to capture')
127 def log_event(link, **other):
128 ...
129 ...
130 assert log_event.wait_call()
131 """
132 if self._logs.is_closed():
134 'subscribe() is not supported for closed captures\nPlease move subscribe() into context manager body',
135 )
136
137 def decorator(func):
138 decorated = callinfo.acallqueue(func)
139 self._logs.subscribe(query, decorated)
140 return decorated
141
142 return decorator
143
144
146 _capture: CapturedLogs | None
147
149 self,
150 *,
151 log_level: LogLevel,
152 parse_line: collections.abc.Callable[[bytes], dict],
153 ):
154 """Capture server."""
155 self._log_level = log_level
156 self._client_cond = asyncio.Condition()
157 self._capture = None
158 self._tasks = []
159 self._parse_line = parse_line
160 self._started = False
161 self._socknames = []
162
163 @property
164 def default_log_level(self) -> LogLevel:
165 """Returns default log level specified on object creation."""
166 return self._log_level
167
168 def getsocknames(self) -> list[tuple]:
169 """Return list of server socket names."""
170 return self._socknames
171
172 @contextlib.asynccontextmanager
173 async def start(
174 self, host='localhost', port=0, **kwargs
175 ) -> typing.AsyncIterator['CaptureServer']:
176 """Starts capture logs asyncio server.
177
178 Arguments are directly passed to `asyncio.start_server`. Server could be started
179 only once. Capture server is returned. Server is closed when contextmanager
180 is finished.
181 """
182 if self._started:
183 raise IncorrectUsageError('Service was already started')
184
185 sockets = net.bind_socket_multiple(host, port)
186 server = await net.start_multiple_servers(
187 self._handle_client, sockets, **kwargs
188 )
189 self._started = True
190 self._socknames = [sock.getsockname() for sock in server.sockets]
191 logger.debug('Logcapture server bound to %r', self._socknames)
192 try:
193 yield self
194 finally:
195 server.close()
196 await server.wait_closed()
197
198 async def wait_for_client(self, timeout: float = 10.0):
199 """Waits for logserver client to connect."""
200
201 async def waiter():
202 async with self._client_cond:
203 await self._client_cond.wait_for(lambda: self._tasks)
204
205 logger.debug('Waiting for logcapture client to connect...')
206 try:
207 await asyncio.wait_for(waiter(), timeout=timeout)
208 except TimeoutError:
210 'Timedout while waiting for logcapture client to connect',
211 )
212
213 async def _handle_client(self, reader, writer):
214 logger.debug('logcapture client connected')
215
216 async def log_reader(capture: CapturedLogs):
217 with contextlib.closing(writer):
218 try:
219 async for line in reader:
220 row = self._parse_line(line)
221 await capture.publish(row)
222 except Exception:
223 async for line in reader:
224 # wait for data transfer to finish
225 pass
226 raise
227 await writer.wait_closed()
228
229 if not self._capture:
230 writer.close()
231 await writer.wait_closed()
232 else:
233 self._tasks.append(asyncio.create_task(log_reader(self._capture)))
234 async with self._client_cond:
235 self._client_cond.notify_all()
236
237 @contextlib.asynccontextmanager
238 async def capture(
239 self,
240 *,
241 log_level: LogLevel | None = None,
242 timeout: float = 10.0,
243 ) -> typing.AsyncIterator[Capture]:
244 """
245 Starts logs capture. Returns `Capture` object.
246 """
247 if self._capture:
248 yield self._capture
249 return
250
251 self._capture = CapturedLogs(log_level=log_level or self._log_level)
252 try:
253 yield Capture(self._capture)
254 finally:
255 self._capture.close()
256 self._capture = None
257 if self._tasks:
258 _, pending = await asyncio.wait(self._tasks, timeout=timeout)
259 self._tasks = []
260 if pending:
261 raise TimeoutError(
262 'Timeout while waiting for capture task to finish',
263 )
264
265
266def _match_entry(row: dict, query: dict) -> bool:
267 for key, value in query.items():
268 if row.get(key) != value:
269 return False
270 return True
271
272
273__tracebackhide__ = traceback.hide(BaseError, FileNotFoundError)