DefEssential 0.1.8__24-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 +511 -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 +242 -0
  45. DefEssential/IO/WebSocket/WebSocket.py +288 -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.8.dist-info/METADATA +86 -0
  101. defessential-0.1.8.dist-info/RECORD +104 -0
  102. defessential-0.1.8.dist-info/WHEEL +6 -0
  103. defessential-0.1.8.dist-info/licenses/LICENSE +674 -0
  104. defessential-0.1.8.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
+ """Match file paths beneath a URL prefix."""
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
+ """Return the requested file from the injected connection."""
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,511 @@
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
+ """Collect metrics at application processing boundaries.
101
+
102
+ A started Metrics instance attaches the collector to application request hooks.
103
+ Transmission time and stream-consumption errors after a response is returned
104
+ are not included.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ ):
110
+ self.lock = Lock()
111
+ self.requests: dict[str, RequestMetrics] = {}
112
+ self.started_at: Optional[float] = None
113
+ self.instance = ''
114
+ self.router: Optional[Router] = None
115
+ self.running: Optional[Callable[[], bool]] = None
116
+ self.request_scope: Optional[Callable[[], Optional[Request]]] = None
117
+ self.observations: dict[str, Observation] = {}
118
+ self.completed: WeakKeyDictionary[Request, tuple[Observation, float, int, bool]] = WeakKeyDictionary()
119
+ self.on_request = ObserveRequest(self)
120
+ self.on_request_response = ObserveResponse(self)
121
+ self.on_request_error = ObserveError(self)
122
+ return
123
+
124
+ def bind(
125
+ self,
126
+ router: Router,
127
+ running: Callable[[], bool],
128
+ request_scope: Callable[[], Optional[Request]],
129
+ ) -> None:
130
+ if self.router is not None and self.router is not router:
131
+ raise ValueError('Use a separate MetricsCollector for each Application')
132
+ self.router = router
133
+ self.running = running
134
+ self.request_scope = request_scope
135
+ return
136
+
137
+ def observation_request(
138
+ self,
139
+ request: Request,
140
+ ) -> Request:
141
+ """Associate a replacement Request with the original request in the current application call."""
142
+ if self.request_scope is None:
143
+ raise RuntimeError('MetricsCollector is not bound to an Application request scope')
144
+ current = self.request_scope()
145
+ return request if current is None else current
146
+
147
+ def reset(self) -> None:
148
+ with self.lock:
149
+ if self.router is None:
150
+ raise RuntimeError('MetricsCollector is not bound to a router')
151
+ if any(stats.active for stats in self.requests.values()):
152
+ raise RuntimeError('Cannot restart Metrics while requests are still active')
153
+ self.requests = {
154
+ route.method + ' ' + route.url: RequestMetrics()
155
+ for route in self.router.routes
156
+ }
157
+ self.requests['<unmatched>'] = RequestMetrics()
158
+ self.started_at = monotonic()
159
+ self.instance = uuid4().hex
160
+ self.observations.clear()
161
+ self.completed.clear()
162
+ return
163
+
164
+ def begin(
165
+ self,
166
+ request: Request,
167
+ ) -> Observation:
168
+ if self.router is None:
169
+ raise RuntimeError('MetricsCollector is not bound to a router')
170
+ matches = self.router.matches(request.path)
171
+ match = matches.get(request.method)
172
+ key = request.method + ' ' + match[0].url if match is not None else '<unmatched>'
173
+ with self.lock:
174
+ if self.started_at is None:
175
+ raise RuntimeError('Metrics has not been started')
176
+ context = Observation(
177
+ key,
178
+ self.observation_request(request).id,
179
+ monotonic(),
180
+ )
181
+ if context.id in self.observations:
182
+ raise RuntimeError('Request is already being observed')
183
+ self.observations[context.id] = context
184
+ stats = self.requests.setdefault(key, RequestMetrics())
185
+ stats.active += 1
186
+ stats.starts[context.id] = context.started
187
+ return context
188
+
189
+ def end(
190
+ self,
191
+ request: Request,
192
+ status: int,
193
+ error: Optional[Exception],
194
+ ) -> None:
195
+ request = self.observation_request(request)
196
+ with self.lock:
197
+ context = self.observations.pop(request.id, None)
198
+ if context is None:
199
+ completed = self.completed.get(request)
200
+ if error is not None and completed is not None and completed[3]:
201
+ observation, duration, second, _ = completed
202
+ stats = self.requests[observation.key]
203
+ stats.success -= 1
204
+ stats.failure += 1
205
+ if second in stats.buckets:
206
+ stats.buckets[second]['success'] -= 1
207
+ stats.buckets[second]['failure'] += 1
208
+ self.completed[request] = (
209
+ observation,
210
+ duration,
211
+ second,
212
+ False,
213
+ )
214
+ return
215
+ stats = self.requests[context.key]
216
+ del stats.starts[context.id]
217
+ stats.active -= 1
218
+ now = monotonic()
219
+ duration = now - context.started
220
+ success = error is None and status < 400
221
+ stats.success += int(success)
222
+ stats.failure += int(not success)
223
+ stats.duration += duration
224
+ stats.prune(now)
225
+ bucket = stats.buckets.setdefault(
226
+ floor(now),
227
+ {
228
+ 'success': 0,
229
+ 'failure': 0,
230
+ 'duration': 0,
231
+ 'max_duration': 0,
232
+ },
233
+ )
234
+ bucket['success'] += int(success)
235
+ bucket['failure'] += int(not success)
236
+ bucket['duration'] += duration
237
+ bucket['max_duration'] = max(bucket['max_duration'], duration)
238
+ self.completed[request] = (
239
+ context,
240
+ duration,
241
+ floor(now),
242
+ success,
243
+ )
244
+ return
245
+
246
+ def snapshot(
247
+ self,
248
+ ) -> dict:
249
+ with self.lock:
250
+ if self.started_at is None or self.running is None or self.router is None:
251
+ raise RuntimeError('Metrics has not been started')
252
+ now = monotonic()
253
+ for stats in self.requests.values():
254
+ stats.prune(now)
255
+ for route in self.router.routes:
256
+ self.requests.setdefault(route.method + ' ' + route.url, RequestMetrics())
257
+ return {
258
+ 'instance': self.instance,
259
+ 'sampled_at': now,
260
+ 'uptime': now - self.started_at,
261
+ 'running': self.running(),
262
+ 'window_end': floor(now),
263
+ 'requests': {
264
+ key: {
265
+ 'active': stats.active,
266
+ 'success': stats.success,
267
+ 'failure': stats.failure,
268
+ 'duration': stats.duration,
269
+ 'oldest_active': now - min(stats.starts.values()) if stats.starts else 0,
270
+ 'windows': {
271
+ str(seconds): stats.window(
272
+ floor(now),
273
+ seconds,
274
+ )
275
+ for seconds in (
276
+ 1,
277
+ 60,
278
+ )
279
+ },
280
+ }
281
+ for key, stats in self.requests.items()
282
+ },
283
+ }
284
+
285
+ def render(
286
+ self,
287
+ ) -> str:
288
+ snapshot = self.snapshot()
289
+ lines = [
290
+ '# HELP defessential_application_running Application lifecycle is running; not an external heartbeat.',
291
+ '# TYPE defessential_application_running gauge',
292
+ f'defessential_application_running {int(snapshot["running"])}',
293
+ '# HELP defessential_application_uptime_seconds Seconds since application startup.',
294
+ '# TYPE defessential_application_uptime_seconds gauge',
295
+ f'defessential_application_uptime_seconds {snapshot["uptime"]}',
296
+ '# HELP defessential_requests_active Requests being processed by the Application.',
297
+ '# TYPE defessential_requests_active gauge',
298
+ '# HELP defessential_requests_total Completed Application requests by outcome.',
299
+ '# TYPE defessential_requests_total counter',
300
+ '# HELP defessential_request_duration_seconds Application processing duration; excludes response transfer.',
301
+ '# TYPE defessential_request_duration_seconds summary',
302
+ '# TYPE defessential_request_oldest_active_seconds gauge',
303
+ '# TYPE defessential_requests_window gauge',
304
+ '# TYPE defessential_request_window_duration_seconds_sum gauge',
305
+ '# TYPE defessential_request_window_duration_seconds_max gauge',
306
+ ]
307
+ for key, stats in snapshot['requests'].items():
308
+ tags = 'route="' + label(key) + '"'
309
+ lines.extend((
310
+ f'defessential_requests_active{{{tags}}} {stats["active"]}',
311
+ f'defessential_requests_total{{{tags},outcome="success"}} {stats["success"]}',
312
+ f'defessential_requests_total{{{tags},outcome="failure"}} {stats["failure"]}',
313
+ f'defessential_request_duration_seconds_count{{{tags}}} {stats["success"] + stats["failure"]}',
314
+ f'defessential_request_duration_seconds_sum{{{tags}}} {stats["duration"]}',
315
+ f'defessential_request_oldest_active_seconds{{{tags}}} {stats["oldest_active"]}',
316
+ ))
317
+ for seconds, window in stats['windows'].items():
318
+ window_tags = tags + ',window_seconds="' + seconds + '"'
319
+ lines.extend((
320
+ f'defessential_requests_window{{{window_tags},outcome="success"}} {window["success"]}',
321
+ f'defessential_requests_window{{{window_tags},outcome="failure"}} {window["failure"]}',
322
+ f'defessential_request_window_duration_seconds_sum{{{window_tags}}} {window["duration"]}',
323
+ f'defessential_request_window_duration_seconds_max{{{window_tags}}} {window["max_duration"]}',
324
+ ))
325
+ return '\n'.join(lines) + '\n'
326
+
327
+
328
+
329
+ class ObserveRequest(ApplicationRequest):
330
+ """Forward application request entry to the shared collector."""
331
+
332
+ def __init__(
333
+ self,
334
+ collector: MetricsCollector,
335
+ ):
336
+ self.collector = collector
337
+ return
338
+
339
+ def __call__(
340
+ self,
341
+ request: Request,
342
+ ) -> Request:
343
+ self.collector.begin(request)
344
+ return request
345
+
346
+
347
+ class ObserveResponse(ApplicationRequestResponse):
348
+ """Collect the state of a returned response or direct-output writer."""
349
+
350
+ def __init__(
351
+ self,
352
+ collector: MetricsCollector,
353
+ ):
354
+ self.collector = collector
355
+ return
356
+
357
+ def __call__(
358
+ self,
359
+ request: Request,
360
+ response: Optional[Response],
361
+ ) -> Optional[Response]:
362
+ status = response.status if response is not None else request.writer.status
363
+ self.collector.end(
364
+ request,
365
+ status,
366
+ None,
367
+ )
368
+ return response
369
+
370
+
371
+ class ObserveError(ApplicationRequestError):
372
+ """Collect failures, including post-response-hook errors, without double-counting completions."""
373
+
374
+ def __init__(
375
+ self,
376
+ collector: MetricsCollector,
377
+ ):
378
+ self.collector = collector
379
+ return
380
+
381
+ def __call__(
382
+ self,
383
+ request: Request,
384
+ error: Exception,
385
+ ) -> None:
386
+ self.collector.end(
387
+ request,
388
+ 500,
389
+ error,
390
+ )
391
+ return
392
+
393
+
394
+ class Metrics(Extension):
395
+ """Observe all application requests and expose metrics as JSON or Prometheus."""
396
+
397
+ logger = getLogger('DefEssential.Extensions.Metrics')
398
+
399
+ def __init__(
400
+ self,
401
+ path: str,
402
+ *,
403
+ collector: Optional[MetricsCollector] = None,
404
+ on_startup: Optional[Union[
405
+ ExtensionStartup,
406
+ Sequence[ExtensionStartup],
407
+ ]] = None,
408
+ on_cleanup: Optional[Union[
409
+ ExtensionCleanup,
410
+ Sequence[ExtensionCleanup],
411
+ ]] = None,
412
+ on_request: Optional[Union[
413
+ ExtensionRequest,
414
+ Sequence[ExtensionRequest],
415
+ ]] = None,
416
+ on_request_response: Optional[Union[
417
+ ExtensionRequestResponse,
418
+ Sequence[ExtensionRequestResponse],
419
+ ]] = None,
420
+ on_request_error: Optional[Union[
421
+ ExtensionRequestError,
422
+ Sequence[ExtensionRequestError],
423
+ ]] = None,
424
+ ):
425
+ super().__init__(
426
+ on_startup=on_startup,
427
+ on_cleanup=on_cleanup,
428
+ on_request=on_request,
429
+ on_request_response=on_request_response,
430
+ on_request_error=on_request_error,
431
+ )
432
+ validate_path(path)
433
+ self.path = path
434
+ self.collector = MetricsCollector() if collector is None else collector
435
+ self.hook_token: Optional[RequestHookRegistration] = None
436
+ return
437
+
438
+ def __str__(self) -> str:
439
+ return '{}({})'.format(type(self).__name__, self.path)
440
+
441
+ def startup(
442
+ self,
443
+ application: Application,
444
+ ) -> None:
445
+ self.application = application
446
+ self.collector.bind(
447
+ application.router,
448
+ lambda: application.running,
449
+ lambda: getattr(application.hook_context, 'request', None),
450
+ )
451
+ self.collector.reset()
452
+ self.logger.debug('Metrics initialized: routes=%s', len(application.router.routes))
453
+ for route in http_routes(self, self.path):
454
+ application.add_extension_route(self, route)
455
+ self.hook_token = application.add_request_hooks(
456
+ on_request=self.collector.on_request,
457
+ on_request_response=self.collector.on_request_response,
458
+ on_request_error=self.collector.on_request_error,
459
+ )
460
+ self.logger.debug('Metrics observation hooks connected')
461
+ try:
462
+ super().startup(application)
463
+ except Exception:
464
+ try:
465
+ application.remove_request_hooks(self.hook_token)
466
+ except Exception:
467
+ self.logger.exception('시작 실패 후 요청 훅 해제에 실패했습니다')
468
+ finally:
469
+ self.hook_token = None
470
+ raise
471
+ return
472
+
473
+ def cleanup(self) -> None:
474
+ error = None
475
+ try:
476
+ if self.hook_token is not None:
477
+ self.application.remove_request_hooks(self.hook_token)
478
+ self.logger.debug('Metrics observation hooks disconnected')
479
+ except Exception as caught:
480
+ error = caught
481
+ finally:
482
+ self.hook_token = None
483
+ try:
484
+ super().cleanup()
485
+ except Exception:
486
+ if error is None:
487
+ raise
488
+ self.logger.exception('요청 훅 해제 실패 후 사용자 정리에 실패했습니다')
489
+ if error is not None:
490
+ raise error
491
+ return
492
+
493
+ def __call__(
494
+ self,
495
+ request: Request,
496
+ ) -> Response:
497
+ if request.header('Accept') == 'application/json':
498
+ self.logger.debug('Metrics snapshot preparing: request_id=%s', request.id)
499
+ response = ResponseJSON(
500
+ self.collector.snapshot(),
501
+ headers={'Cache-Control': 'no-store'},
502
+ )
503
+ else:
504
+ self.logger.debug('Metrics text rendering: request_id=%s', request.id)
505
+ response = Response(
506
+ body=self.collector.render().encode('utf-8'),
507
+ format='text/plain; version=0.0.4',
508
+ charset='utf-8',
509
+ )
510
+ self.logger.debug('Metrics response prepared: request_id=%s', request.id)
511
+ return response