DefEssential 0.1.7__21-py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. DefEssential/Adapter/Implements/Gunicorn/Server.py +152 -0
  2. DefEssential/Adapter/Implements/Gunicorn/Transport.py +171 -0
  3. DefEssential/Adapter/Implements/Gunicorn/__init__.py +5 -0
  4. DefEssential/Adapter/Implements/Python/Server.py +273 -0
  5. DefEssential/Adapter/Implements/Python/__init__.py +5 -0
  6. DefEssential/Adapter/Implements/Uvicorn/HTTP.py +256 -0
  7. DefEssential/Adapter/Implements/Uvicorn/Portal.py +65 -0
  8. DefEssential/Adapter/Implements/Uvicorn/Server.py +137 -0
  9. DefEssential/Adapter/Implements/Uvicorn/WebSocket.py +222 -0
  10. DefEssential/Adapter/Implements/Uvicorn/__init__.py +5 -0
  11. DefEssential/Adapter/Implements/__init__.py +3 -0
  12. DefEssential/Adapter/RequestHandler.py +109 -0
  13. DefEssential/Adapter/Server.py +54 -0
  14. DefEssential/Adapter/WSGI.py +219 -0
  15. DefEssential/Adapter/__init__.py +5 -0
  16. DefEssential/Application.py +657 -0
  17. DefEssential/Body.py +50 -0
  18. DefEssential/Content.py +18 -0
  19. DefEssential/Credentials.py +245 -0
  20. DefEssential/CrossOriginResourceSharing.py +107 -0
  21. DefEssential/Descriptor/DataObject.py +409 -0
  22. DefEssential/Descriptor/Types.py +248 -0
  23. DefEssential/Descriptor/Value.py +188 -0
  24. DefEssential/Descriptor/__init__.py +172 -0
  25. DefEssential/Error.py +66 -0
  26. DefEssential/Extension.py +191 -0
  27. DefEssential/Extensions/File.py +95 -0
  28. DefEssential/Extensions/FileSystem.py +195 -0
  29. DefEssential/Extensions/Metrics.py +510 -0
  30. DefEssential/Extensions/Ping.py +93 -0
  31. DefEssential/Extensions/Specifications.py +342 -0
  32. DefEssential/Extensions/Status.html +207 -0
  33. DefEssential/Extensions/Status.py +110 -0
  34. DefEssential/Extensions/Swagger.html +77 -0
  35. DefEssential/Extensions/Swagger.py +117 -0
  36. DefEssential/Extensions/Utils/Routes.py +42 -0
  37. DefEssential/Extensions/Utils/__init__.py +43 -0
  38. DefEssential/Extensions/__init__.py +21 -0
  39. DefEssential/IO/Readers/ChunkedStream.py +106 -0
  40. DefEssential/IO/Readers/ContentReader.py +36 -0
  41. DefEssential/IO/Readers/ContentReaders.py +241 -0
  42. DefEssential/IO/Readers/__init__.py +25 -0
  43. DefEssential/IO/RequestReader.py +125 -0
  44. DefEssential/IO/ResponseWriter.py +241 -0
  45. DefEssential/IO/WebSocket/WebSocket.py +286 -0
  46. DefEssential/IO/WebSocket/WebSocketError.py +17 -0
  47. DefEssential/IO/WebSocket/WebSocketFrame.py +68 -0
  48. DefEssential/IO/WebSocket/WebSocketFrameReader.py +98 -0
  49. DefEssential/IO/WebSocket/WebSocketHandshake.py +94 -0
  50. DefEssential/IO/WebSocket/__init__.py +13 -0
  51. DefEssential/IO/Writers/ChunkedStreamWriter.py +19 -0
  52. DefEssential/IO/Writers/ServerSentEvents.py +41 -0
  53. DefEssential/IO/Writers/ServerSentEventsWriter.py +67 -0
  54. DefEssential/IO/Writers/StreamWriter.py +57 -0
  55. DefEssential/IO/Writers/__init__.py +11 -0
  56. DefEssential/IO/__init__.py +7 -0
  57. DefEssential/Request.py +110 -0
  58. DefEssential/RequestRunner.py +261 -0
  59. DefEssential/Response.py +89 -0
  60. DefEssential/ResponseSpecification.py +23 -0
  61. DefEssential/Responses/ResponseFile.py +108 -0
  62. DefEssential/Responses/__init__.py +63 -0
  63. DefEssential/Route.py +55 -0
  64. DefEssential/Router.py +48 -0
  65. DefEssential/Runners/RunExtension.py +116 -0
  66. DefEssential/Runners/RunFactory.py +26 -0
  67. DefEssential/Runners/RunOptions.py +77 -0
  68. DefEssential/Runners/RunRequest.py +198 -0
  69. DefEssential/Runners/__init__.py +5 -0
  70. DefEssential/Startup.py +104 -0
  71. DefEssential/Utils/FileSystemObject/Configuration.py +15 -0
  72. DefEssential/Utils/FileSystemObject/Connection.py +57 -0
  73. DefEssential/Utils/FileSystemObject/File.py +41 -0
  74. DefEssential/Utils/FileSystemObject/Implements/FileSystem/Configuration.py +15 -0
  75. DefEssential/Utils/FileSystemObject/Implements/FileSystem/Connection.py +115 -0
  76. DefEssential/Utils/FileSystemObject/Implements/FileSystem/File.py +77 -0
  77. DefEssential/Utils/FileSystemObject/Implements/FileSystem/__init__.py +11 -0
  78. DefEssential/Utils/FileSystemObject/Implements/__init__.py +3 -0
  79. DefEssential/Utils/FileSystemObject/__init__.py +11 -0
  80. DefEssential/Utils/Loader.py +81 -0
  81. DefEssential/Utils/__init__.py +3 -0
  82. DefEssential/Validator/Pattern.py +36 -0
  83. DefEssential/Validator/Patterns/Array.py +30 -0
  84. DefEssential/Validator/Patterns/Common.py +92 -0
  85. DefEssential/Validator/Patterns/Compare.py +180 -0
  86. DefEssential/Validator/Patterns/Condition.py +60 -0
  87. DefEssential/Validator/Patterns/DataObject.py +85 -0
  88. DefEssential/Validator/Patterns/DateTime.py +111 -0
  89. DefEssential/Validator/Patterns/Number.py +43 -0
  90. DefEssential/Validator/Patterns/Object.py +150 -0
  91. DefEssential/Validator/Patterns/Size.py +110 -0
  92. DefEssential/Validator/Patterns/String.py +85 -0
  93. DefEssential/Validator/Patterns/Type.py +350 -0
  94. DefEssential/Validator/Patterns/__init__.py +137 -0
  95. DefEssential/Validator/Validate.py +29 -0
  96. DefEssential/Validator/Validator.py +30 -0
  97. DefEssential/Validator/__init__.py +11 -0
  98. DefEssential/Validators.py +255 -0
  99. DefEssential/__init__.py +83 -0
  100. defessential-0.1.7.dist-info/METADATA +41 -0
  101. defessential-0.1.7.dist-info/RECORD +104 -0
  102. defessential-0.1.7.dist-info/WHEEL +6 -0
  103. defessential-0.1.7.dist-info/licenses/LICENSE +674 -0
  104. defessential-0.1.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,195 @@
1
+ from logging import getLogger
2
+ from typing import (
3
+ Optional,
4
+ Sequence,
5
+ Type,
6
+ Union,
7
+ )
8
+ from urllib.parse import unquote
9
+
10
+ from ..Application import Application
11
+ from ..Extension import (
12
+ Extension,
13
+ ExtensionCleanup,
14
+ ExtensionRequest,
15
+ ExtensionRequestError,
16
+ ExtensionRequestResponse,
17
+ ExtensionStartup,
18
+ )
19
+ from ..Request import Request
20
+ from ..RequestRunner import RequestRunner
21
+ from ..Route import Route
22
+ from ..Runners.RunExtension import RunExtensionFactory
23
+ from ..Responses import ResponseFile
24
+ from ..Responses.ResponseFile import file_error
25
+ from ..Utils.FileSystemObject import Connection
26
+ from .Utils.Routes import (
27
+ validate_path,
28
+ )
29
+
30
+ __all__ = (
31
+ 'FileSystem',
32
+ 'FileSystemRoute',
33
+ )
34
+
35
+
36
+ class FileSystemRoute(Route):
37
+ """URL 접두사 아래의 하위 파일 경로를 매칭한다."""
38
+
39
+ def __init__(
40
+ self,
41
+ prefix: str,
42
+ obj: Type[RequestRunner],
43
+ *,
44
+ method: str = 'GET',
45
+ ):
46
+ super().__init__(
47
+ method,
48
+ prefix,
49
+ obj,
50
+ )
51
+ self.prefix = prefix.rstrip('/') + '/'
52
+ return
53
+
54
+ @property
55
+ def priority(self) -> tuple[int, int, int]:
56
+ return (
57
+ 1,
58
+ 0,
59
+ -len(self.prefix),
60
+ )
61
+
62
+ def match(
63
+ self,
64
+ url: str,
65
+ ) -> Optional[dict[str, str]]:
66
+ if url.startswith(self.prefix):
67
+ return {'path': unquote(url[len(self.prefix):])}
68
+ return None
69
+
70
+
71
+ class FileSystem(Extension):
72
+ """주입된 연결에서 요청 파일을 응답한다."""
73
+
74
+ logger = getLogger('DefEssential.Extensions.FileSystem')
75
+
76
+ def __init__(
77
+ self,
78
+ path: str,
79
+ connection: Connection,
80
+ *,
81
+ on_startup: Optional[Union[
82
+ ExtensionStartup,
83
+ Sequence[ExtensionStartup],
84
+ ]] = None,
85
+ on_cleanup: Optional[Union[
86
+ ExtensionCleanup,
87
+ Sequence[ExtensionCleanup],
88
+ ]] = None,
89
+ on_request: Optional[Union[
90
+ ExtensionRequest,
91
+ Sequence[ExtensionRequest],
92
+ ]] = None,
93
+ on_request_response: Optional[Union[
94
+ ExtensionRequestResponse,
95
+ Sequence[ExtensionRequestResponse],
96
+ ]] = None,
97
+ on_request_error: Optional[Union[
98
+ ExtensionRequestError,
99
+ Sequence[ExtensionRequestError],
100
+ ]] = None,
101
+ ):
102
+ super().__init__(
103
+ on_startup=on_startup,
104
+ on_cleanup=on_cleanup,
105
+ on_request=on_request,
106
+ on_request_response=on_request_response,
107
+ on_request_error=on_request_error,
108
+ )
109
+ validate_path(path)
110
+ self.path = path
111
+ self.connection = connection
112
+ return
113
+
114
+ def __str__(self) -> str:
115
+ return '{}(uri={}, path={})'.format(type(self).__name__, self.path, self.connection.base) # type: ignore
116
+
117
+ def startup(
118
+ self,
119
+ application: Application,
120
+ ) -> None:
121
+ try:
122
+ for method in (
123
+ 'GET',
124
+ 'HEAD',
125
+ ):
126
+ application.add_extension_route(
127
+ self,
128
+ FileSystemRoute(
129
+ self.path,
130
+ RunExtensionFactory(
131
+ self,
132
+ method,
133
+ self.path,
134
+ )(),
135
+ method=method,
136
+ ),
137
+ )
138
+ self.logger.debug('FileSystem connecting')
139
+ self.connection.connect()
140
+ self.logger.debug('FileSystem connected')
141
+ super().startup(application)
142
+ except Exception:
143
+ try:
144
+ self.logger.debug('FileSystem rolling back')
145
+ self.connection.close()
146
+ self.logger.debug('FileSystem rolled back')
147
+ except Exception:
148
+ self.logger.exception('시작 실패 후 연결 정리에 실패했습니다')
149
+ raise
150
+ return
151
+
152
+ def cleanup(self) -> None:
153
+ error = None
154
+ try:
155
+ super().cleanup()
156
+ except Exception as caught:
157
+ error = caught
158
+ try:
159
+ self.logger.debug('FileSystem closing')
160
+ self.connection.close()
161
+ self.logger.debug('FileSystem closed')
162
+ except Exception:
163
+ if error is None:
164
+ raise
165
+ self.logger.exception('사용자 훅 실패 후 연결 정리에 실패했습니다')
166
+ if error is not None:
167
+ raise error
168
+ return
169
+
170
+ def __call__(
171
+ self,
172
+ request: Request,
173
+ ) -> ResponseFile:
174
+ path = request.parameters['path']
175
+ self.logger.debug('File response preparing: request_id=%s', request.id)
176
+ try:
177
+ format, encoding = self.connection.type(path)
178
+ file = self.connection.open(
179
+ path,
180
+ mode='rb',
181
+ )
182
+ except (
183
+ FileNotFoundError,
184
+ NotADirectoryError,
185
+ IsADirectoryError,
186
+ PermissionError,
187
+ ) as error:
188
+ self.logger.debug('File response rejected: request_id=%s error_type=%s', request.id, type(error).__name__)
189
+ raise file_error(error) from error
190
+ self.logger.debug('File response prepared: request_id=%s', request.id)
191
+ return ResponseFile(
192
+ file,
193
+ format=format,
194
+ encoding=encoding,
195
+ )
@@ -0,0 +1,510 @@
1
+ from dataclasses import (
2
+ dataclass,
3
+ field,
4
+ )
5
+ from logging import getLogger
6
+ from math import floor
7
+ from threading import Lock
8
+ from time import monotonic
9
+ from typing import (
10
+ Callable,
11
+ Optional,
12
+ Sequence,
13
+ Union,
14
+ )
15
+ from uuid import uuid4
16
+ from weakref import WeakKeyDictionary
17
+
18
+ from ..Application import (
19
+ Application,
20
+ ApplicationRequest,
21
+ ApplicationRequestError,
22
+ ApplicationRequestResponse,
23
+ RequestHookRegistration,
24
+ )
25
+ from ..Extension import (
26
+ Extension,
27
+ ExtensionCleanup,
28
+ ExtensionRequest,
29
+ ExtensionRequestError,
30
+ ExtensionRequestResponse,
31
+ ExtensionStartup,
32
+ )
33
+ from ..Request import Request
34
+ from ..Response import Response
35
+ from ..Responses import ResponseJSON
36
+ from ..Router import Router
37
+ from .Utils.Routes import (
38
+ http_routes,
39
+ validate_path,
40
+ )
41
+
42
+ __all__ = (
43
+ 'RequestMetrics',
44
+ 'Observation',
45
+ 'Metrics',
46
+ 'MetricsCollector',
47
+ )
48
+
49
+
50
+ @dataclass
51
+ class RequestMetrics:
52
+ active: int = 0
53
+ success: int = 0
54
+ failure: int = 0
55
+ duration: float = 0
56
+ starts: dict[str, float] = field(default_factory=dict)
57
+ buckets: dict[int, dict[str, float]] = field(default_factory=dict)
58
+
59
+ def prune(
60
+ self,
61
+ now: float,
62
+ ) -> None:
63
+ for second in tuple(self.buckets):
64
+ if second < floor(now) - 60:
65
+ del self.buckets[second]
66
+ return
67
+
68
+ def window(
69
+ self,
70
+ end: int,
71
+ seconds: int,
72
+ ) -> dict:
73
+ buckets = [
74
+ bucket for second, bucket in self.buckets.items()
75
+ if end - seconds <= second < end
76
+ ]
77
+ return {
78
+ 'success': sum(bucket['success'] for bucket in buckets),
79
+ 'failure': sum(bucket['failure'] for bucket in buckets),
80
+ 'duration': sum(bucket['duration'] for bucket in buckets),
81
+ 'max_duration': max(
82
+ (bucket['max_duration'] for bucket in buckets),
83
+ default=0,
84
+ ),
85
+ }
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Observation:
90
+ key: str
91
+ id: str
92
+ started: float
93
+
94
+
95
+ def label(value: str) -> str:
96
+ return value.replace('\\', '\\\\').replace('\n', '\\n').replace('"', '\\"')
97
+
98
+
99
+ class MetricsCollector:
100
+ """Application 처리 경계의 통계를 집계한다.
101
+
102
+ 시작한 Metrics가 Application 요청 훅에 수집기를 연결한다. 응답 반환 이후의
103
+ 전송 시간과 스트림 소비 오류는 집계하지 않는다.
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ ):
109
+ self.lock = Lock()
110
+ self.requests: dict[str, RequestMetrics] = {}
111
+ self.started_at: Optional[float] = None
112
+ self.instance = ''
113
+ self.router: Optional[Router] = None
114
+ self.running: Optional[Callable[[], bool]] = None
115
+ self.request_scope: Optional[Callable[[], Optional[Request]]] = None
116
+ self.observations: dict[str, Observation] = {}
117
+ self.completed: WeakKeyDictionary[Request, tuple[Observation, float, int, bool]] = WeakKeyDictionary()
118
+ self.on_request = ObserveRequest(self)
119
+ self.on_request_response = ObserveResponse(self)
120
+ self.on_request_error = ObserveError(self)
121
+ return
122
+
123
+ def bind(
124
+ self,
125
+ router: Router,
126
+ running: Callable[[], bool],
127
+ request_scope: Callable[[], Optional[Request]],
128
+ ) -> None:
129
+ if self.router is not None and self.router is not router:
130
+ raise ValueError('Use a separate MetricsCollector for each Application')
131
+ self.router = router
132
+ self.running = running
133
+ self.request_scope = request_scope
134
+ return
135
+
136
+ def observation_request(
137
+ self,
138
+ request: Request,
139
+ ) -> Request:
140
+ """교체된 Request도 현재 Application 호출의 최초 요청과 연결한다."""
141
+ if self.request_scope is None:
142
+ raise RuntimeError('MetricsCollector is not bound to an Application request scope')
143
+ current = self.request_scope()
144
+ return request if current is None else current
145
+
146
+ def reset(self) -> None:
147
+ with self.lock:
148
+ if self.router is None:
149
+ raise RuntimeError('MetricsCollector is not bound to a router')
150
+ if any(stats.active for stats in self.requests.values()):
151
+ raise RuntimeError('Cannot restart Metrics while requests are still active')
152
+ self.requests = {
153
+ route.method + ' ' + route.url: RequestMetrics()
154
+ for route in self.router.routes
155
+ }
156
+ self.requests['<unmatched>'] = RequestMetrics()
157
+ self.started_at = monotonic()
158
+ self.instance = uuid4().hex
159
+ self.observations.clear()
160
+ self.completed.clear()
161
+ return
162
+
163
+ def begin(
164
+ self,
165
+ request: Request,
166
+ ) -> Observation:
167
+ if self.router is None:
168
+ raise RuntimeError('MetricsCollector is not bound to a router')
169
+ matches = self.router.matches(request.path)
170
+ match = matches.get(request.method)
171
+ key = request.method + ' ' + match[0].url if match is not None else '<unmatched>'
172
+ with self.lock:
173
+ if self.started_at is None:
174
+ raise RuntimeError('Metrics has not been started')
175
+ context = Observation(
176
+ key,
177
+ self.observation_request(request).id,
178
+ monotonic(),
179
+ )
180
+ if context.id in self.observations:
181
+ raise RuntimeError('Request is already being observed')
182
+ self.observations[context.id] = context
183
+ stats = self.requests.setdefault(key, RequestMetrics())
184
+ stats.active += 1
185
+ stats.starts[context.id] = context.started
186
+ return context
187
+
188
+ def end(
189
+ self,
190
+ request: Request,
191
+ status: int,
192
+ error: Optional[Exception],
193
+ ) -> None:
194
+ request = self.observation_request(request)
195
+ with self.lock:
196
+ context = self.observations.pop(request.id, None)
197
+ if context is None:
198
+ completed = self.completed.get(request)
199
+ if error is not None and completed is not None and completed[3]:
200
+ observation, duration, second, _ = completed
201
+ stats = self.requests[observation.key]
202
+ stats.success -= 1
203
+ stats.failure += 1
204
+ if second in stats.buckets:
205
+ stats.buckets[second]['success'] -= 1
206
+ stats.buckets[second]['failure'] += 1
207
+ self.completed[request] = (
208
+ observation,
209
+ duration,
210
+ second,
211
+ False,
212
+ )
213
+ return
214
+ stats = self.requests[context.key]
215
+ del stats.starts[context.id]
216
+ stats.active -= 1
217
+ now = monotonic()
218
+ duration = now - context.started
219
+ success = error is None and status < 400
220
+ stats.success += int(success)
221
+ stats.failure += int(not success)
222
+ stats.duration += duration
223
+ stats.prune(now)
224
+ bucket = stats.buckets.setdefault(
225
+ floor(now),
226
+ {
227
+ 'success': 0,
228
+ 'failure': 0,
229
+ 'duration': 0,
230
+ 'max_duration': 0,
231
+ },
232
+ )
233
+ bucket['success'] += int(success)
234
+ bucket['failure'] += int(not success)
235
+ bucket['duration'] += duration
236
+ bucket['max_duration'] = max(bucket['max_duration'], duration)
237
+ self.completed[request] = (
238
+ context,
239
+ duration,
240
+ floor(now),
241
+ success,
242
+ )
243
+ return
244
+
245
+ def snapshot(
246
+ self,
247
+ ) -> dict:
248
+ with self.lock:
249
+ if self.started_at is None or self.running is None or self.router is None:
250
+ raise RuntimeError('Metrics has not been started')
251
+ now = monotonic()
252
+ for stats in self.requests.values():
253
+ stats.prune(now)
254
+ for route in self.router.routes:
255
+ self.requests.setdefault(route.method + ' ' + route.url, RequestMetrics())
256
+ return {
257
+ 'instance': self.instance,
258
+ 'sampled_at': now,
259
+ 'uptime': now - self.started_at,
260
+ 'running': self.running(),
261
+ 'window_end': floor(now),
262
+ 'requests': {
263
+ key: {
264
+ 'active': stats.active,
265
+ 'success': stats.success,
266
+ 'failure': stats.failure,
267
+ 'duration': stats.duration,
268
+ 'oldest_active': now - min(stats.starts.values()) if stats.starts else 0,
269
+ 'windows': {
270
+ str(seconds): stats.window(
271
+ floor(now),
272
+ seconds,
273
+ )
274
+ for seconds in (
275
+ 1,
276
+ 60,
277
+ )
278
+ },
279
+ }
280
+ for key, stats in self.requests.items()
281
+ },
282
+ }
283
+
284
+ def render(
285
+ self,
286
+ ) -> str:
287
+ snapshot = self.snapshot()
288
+ lines = [
289
+ '# HELP defessential_application_running Application lifecycle is running; not an external heartbeat.',
290
+ '# TYPE defessential_application_running gauge',
291
+ f'defessential_application_running {int(snapshot["running"])}',
292
+ '# HELP defessential_application_uptime_seconds Seconds since application startup.',
293
+ '# TYPE defessential_application_uptime_seconds gauge',
294
+ f'defessential_application_uptime_seconds {snapshot["uptime"]}',
295
+ '# HELP defessential_requests_active Requests being processed by the Application.',
296
+ '# TYPE defessential_requests_active gauge',
297
+ '# HELP defessential_requests_total Completed Application requests by outcome.',
298
+ '# TYPE defessential_requests_total counter',
299
+ '# HELP defessential_request_duration_seconds Application processing duration; excludes response transfer.',
300
+ '# TYPE defessential_request_duration_seconds summary',
301
+ '# TYPE defessential_request_oldest_active_seconds gauge',
302
+ '# TYPE defessential_requests_window gauge',
303
+ '# TYPE defessential_request_window_duration_seconds_sum gauge',
304
+ '# TYPE defessential_request_window_duration_seconds_max gauge',
305
+ ]
306
+ for key, stats in snapshot['requests'].items():
307
+ tags = 'route="' + label(key) + '"'
308
+ lines.extend((
309
+ f'defessential_requests_active{{{tags}}} {stats["active"]}',
310
+ f'defessential_requests_total{{{tags},outcome="success"}} {stats["success"]}',
311
+ f'defessential_requests_total{{{tags},outcome="failure"}} {stats["failure"]}',
312
+ f'defessential_request_duration_seconds_count{{{tags}}} {stats["success"] + stats["failure"]}',
313
+ f'defessential_request_duration_seconds_sum{{{tags}}} {stats["duration"]}',
314
+ f'defessential_request_oldest_active_seconds{{{tags}}} {stats["oldest_active"]}',
315
+ ))
316
+ for seconds, window in stats['windows'].items():
317
+ window_tags = tags + ',window_seconds="' + seconds + '"'
318
+ lines.extend((
319
+ f'defessential_requests_window{{{window_tags},outcome="success"}} {window["success"]}',
320
+ f'defessential_requests_window{{{window_tags},outcome="failure"}} {window["failure"]}',
321
+ f'defessential_request_window_duration_seconds_sum{{{window_tags}}} {window["duration"]}',
322
+ f'defessential_request_window_duration_seconds_max{{{window_tags}}} {window["max_duration"]}',
323
+ ))
324
+ return '\n'.join(lines) + '\n'
325
+
326
+
327
+
328
+ class ObserveRequest(ApplicationRequest):
329
+ """Application 요청 진입을 공유 수집기에 전달한다."""
330
+
331
+ def __init__(
332
+ self,
333
+ collector: MetricsCollector,
334
+ ):
335
+ self.collector = collector
336
+ return
337
+
338
+ def __call__(
339
+ self,
340
+ request: Request,
341
+ ) -> Request:
342
+ self.collector.begin(request)
343
+ return request
344
+
345
+
346
+ class ObserveResponse(ApplicationRequestResponse):
347
+ """반환 응답 또는 직접 출력 writer의 상태를 집계한다."""
348
+
349
+ def __init__(
350
+ self,
351
+ collector: MetricsCollector,
352
+ ):
353
+ self.collector = collector
354
+ return
355
+
356
+ def __call__(
357
+ self,
358
+ request: Request,
359
+ response: Optional[Response],
360
+ ) -> Optional[Response]:
361
+ status = response.status if response is not None else request.writer.status
362
+ self.collector.end(
363
+ request,
364
+ status,
365
+ None,
366
+ )
367
+ return response
368
+
369
+
370
+ class ObserveError(ApplicationRequestError):
371
+ """실패를 집계하며 응답 훅 이후의 오류도 중복 완료 없이 반영한다."""
372
+
373
+ def __init__(
374
+ self,
375
+ collector: MetricsCollector,
376
+ ):
377
+ self.collector = collector
378
+ return
379
+
380
+ def __call__(
381
+ self,
382
+ request: Request,
383
+ error: Exception,
384
+ ) -> None:
385
+ self.collector.end(
386
+ request,
387
+ 500,
388
+ error,
389
+ )
390
+ return
391
+
392
+
393
+ class Metrics(Extension):
394
+ """전체 Application 요청을 관찰하고 JSON 또는 Prometheus로 응답한다."""
395
+
396
+ logger = getLogger('DefEssential.Extensions.Metrics')
397
+
398
+ def __init__(
399
+ self,
400
+ path: str,
401
+ *,
402
+ collector: Optional[MetricsCollector] = None,
403
+ on_startup: Optional[Union[
404
+ ExtensionStartup,
405
+ Sequence[ExtensionStartup],
406
+ ]] = None,
407
+ on_cleanup: Optional[Union[
408
+ ExtensionCleanup,
409
+ Sequence[ExtensionCleanup],
410
+ ]] = None,
411
+ on_request: Optional[Union[
412
+ ExtensionRequest,
413
+ Sequence[ExtensionRequest],
414
+ ]] = None,
415
+ on_request_response: Optional[Union[
416
+ ExtensionRequestResponse,
417
+ Sequence[ExtensionRequestResponse],
418
+ ]] = None,
419
+ on_request_error: Optional[Union[
420
+ ExtensionRequestError,
421
+ Sequence[ExtensionRequestError],
422
+ ]] = None,
423
+ ):
424
+ super().__init__(
425
+ on_startup=on_startup,
426
+ on_cleanup=on_cleanup,
427
+ on_request=on_request,
428
+ on_request_response=on_request_response,
429
+ on_request_error=on_request_error,
430
+ )
431
+ validate_path(path)
432
+ self.path = path
433
+ self.collector = MetricsCollector() if collector is None else collector
434
+ self.hook_token: Optional[RequestHookRegistration] = None
435
+ return
436
+
437
+ def __str__(self) -> str:
438
+ return '{}({})'.format(type(self).__name__, self.path)
439
+
440
+ def startup(
441
+ self,
442
+ application: Application,
443
+ ) -> None:
444
+ self.application = application
445
+ self.collector.bind(
446
+ application.router,
447
+ lambda: application.running,
448
+ lambda: getattr(application.hook_context, 'request', None),
449
+ )
450
+ self.collector.reset()
451
+ self.logger.debug('Metrics initialized: routes=%s', len(application.router.routes))
452
+ for route in http_routes(self, self.path):
453
+ application.add_extension_route(self, route)
454
+ self.hook_token = application.add_request_hooks(
455
+ on_request=self.collector.on_request,
456
+ on_request_response=self.collector.on_request_response,
457
+ on_request_error=self.collector.on_request_error,
458
+ )
459
+ self.logger.debug('Metrics observation hooks connected')
460
+ try:
461
+ super().startup(application)
462
+ except Exception:
463
+ try:
464
+ application.remove_request_hooks(self.hook_token)
465
+ except Exception:
466
+ self.logger.exception('시작 실패 후 요청 훅 해제에 실패했습니다')
467
+ finally:
468
+ self.hook_token = None
469
+ raise
470
+ return
471
+
472
+ def cleanup(self) -> None:
473
+ error = None
474
+ try:
475
+ if self.hook_token is not None:
476
+ self.application.remove_request_hooks(self.hook_token)
477
+ self.logger.debug('Metrics observation hooks disconnected')
478
+ except Exception as caught:
479
+ error = caught
480
+ finally:
481
+ self.hook_token = None
482
+ try:
483
+ super().cleanup()
484
+ except Exception:
485
+ if error is None:
486
+ raise
487
+ self.logger.exception('요청 훅 해제 실패 후 사용자 정리에 실패했습니다')
488
+ if error is not None:
489
+ raise error
490
+ return
491
+
492
+ def __call__(
493
+ self,
494
+ request: Request,
495
+ ) -> Response:
496
+ if request.header('Accept') == 'application/json':
497
+ self.logger.debug('Metrics snapshot preparing: request_id=%s', request.id)
498
+ response = ResponseJSON(
499
+ self.collector.snapshot(),
500
+ headers={'Cache-Control': 'no-store'},
501
+ )
502
+ else:
503
+ self.logger.debug('Metrics text rendering: request_id=%s', request.id)
504
+ response = Response(
505
+ body=self.collector.render().encode('utf-8'),
506
+ format='text/plain; version=0.0.4',
507
+ charset='utf-8',
508
+ )
509
+ self.logger.debug('Metrics response prepared: request_id=%s', request.id)
510
+ return response