DefEssential 0.1.8__27-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.
- DefEssential/Adapter/Implements/Gunicorn/Server.py +152 -0
- DefEssential/Adapter/Implements/Gunicorn/Transport.py +171 -0
- DefEssential/Adapter/Implements/Gunicorn/__init__.py +5 -0
- DefEssential/Adapter/Implements/Python/Server.py +273 -0
- DefEssential/Adapter/Implements/Python/__init__.py +5 -0
- DefEssential/Adapter/Implements/Uvicorn/HTTP.py +256 -0
- DefEssential/Adapter/Implements/Uvicorn/Portal.py +65 -0
- DefEssential/Adapter/Implements/Uvicorn/Server.py +137 -0
- DefEssential/Adapter/Implements/Uvicorn/WebSocket.py +222 -0
- DefEssential/Adapter/Implements/Uvicorn/__init__.py +5 -0
- DefEssential/Adapter/Implements/__init__.py +3 -0
- DefEssential/Adapter/RequestHandler.py +109 -0
- DefEssential/Adapter/Server.py +54 -0
- DefEssential/Adapter/WSGI.py +219 -0
- DefEssential/Adapter/__init__.py +5 -0
- DefEssential/Application.py +657 -0
- DefEssential/Body.py +50 -0
- DefEssential/Content.py +18 -0
- DefEssential/Credentials.py +245 -0
- DefEssential/CrossOriginResourceSharing.py +107 -0
- DefEssential/Descriptor/DataObject.py +409 -0
- DefEssential/Descriptor/Types.py +248 -0
- DefEssential/Descriptor/Value.py +188 -0
- DefEssential/Descriptor/__init__.py +172 -0
- DefEssential/Error.py +66 -0
- DefEssential/Extension.py +191 -0
- DefEssential/Extensions/File.py +95 -0
- DefEssential/Extensions/FileSystem.py +195 -0
- DefEssential/Extensions/Metrics.py +511 -0
- DefEssential/Extensions/Ping.py +93 -0
- DefEssential/Extensions/Specifications.py +342 -0
- DefEssential/Extensions/Status.html +207 -0
- DefEssential/Extensions/Status.py +110 -0
- DefEssential/Extensions/Swagger.html +77 -0
- DefEssential/Extensions/Swagger.py +117 -0
- DefEssential/Extensions/Utils/Routes.py +42 -0
- DefEssential/Extensions/Utils/__init__.py +43 -0
- DefEssential/Extensions/__init__.py +21 -0
- DefEssential/IO/Readers/ChunkedStream.py +106 -0
- DefEssential/IO/Readers/ContentReader.py +36 -0
- DefEssential/IO/Readers/ContentReaders.py +241 -0
- DefEssential/IO/Readers/__init__.py +25 -0
- DefEssential/IO/RequestReader.py +125 -0
- DefEssential/IO/ResponseWriter.py +242 -0
- DefEssential/IO/WebSocket/WebSocket.py +288 -0
- DefEssential/IO/WebSocket/WebSocketError.py +17 -0
- DefEssential/IO/WebSocket/WebSocketFrame.py +68 -0
- DefEssential/IO/WebSocket/WebSocketFrameReader.py +98 -0
- DefEssential/IO/WebSocket/WebSocketHandshake.py +94 -0
- DefEssential/IO/WebSocket/__init__.py +13 -0
- DefEssential/IO/Writers/ChunkedStreamWriter.py +19 -0
- DefEssential/IO/Writers/ServerSentEvents.py +41 -0
- DefEssential/IO/Writers/ServerSentEventsWriter.py +67 -0
- DefEssential/IO/Writers/StreamWriter.py +57 -0
- DefEssential/IO/Writers/__init__.py +11 -0
- DefEssential/IO/__init__.py +7 -0
- DefEssential/Request.py +110 -0
- DefEssential/RequestRunner.py +261 -0
- DefEssential/Response.py +89 -0
- DefEssential/ResponseSpecification.py +23 -0
- DefEssential/Responses/ResponseFile.py +108 -0
- DefEssential/Responses/__init__.py +63 -0
- DefEssential/Route.py +55 -0
- DefEssential/Router.py +48 -0
- DefEssential/Runners/RunExtension.py +116 -0
- DefEssential/Runners/RunFactory.py +26 -0
- DefEssential/Runners/RunOptions.py +77 -0
- DefEssential/Runners/RunRequest.py +198 -0
- DefEssential/Runners/__init__.py +5 -0
- DefEssential/Startup.py +104 -0
- DefEssential/Utils/FileSystemObject/Configuration.py +15 -0
- DefEssential/Utils/FileSystemObject/Connection.py +57 -0
- DefEssential/Utils/FileSystemObject/File.py +41 -0
- DefEssential/Utils/FileSystemObject/Implements/FileSystem/Configuration.py +15 -0
- DefEssential/Utils/FileSystemObject/Implements/FileSystem/Connection.py +115 -0
- DefEssential/Utils/FileSystemObject/Implements/FileSystem/File.py +77 -0
- DefEssential/Utils/FileSystemObject/Implements/FileSystem/__init__.py +11 -0
- DefEssential/Utils/FileSystemObject/Implements/__init__.py +3 -0
- DefEssential/Utils/FileSystemObject/__init__.py +11 -0
- DefEssential/Utils/Loader.py +81 -0
- DefEssential/Utils/__init__.py +3 -0
- DefEssential/Validator/Pattern.py +36 -0
- DefEssential/Validator/Patterns/Array.py +30 -0
- DefEssential/Validator/Patterns/Common.py +92 -0
- DefEssential/Validator/Patterns/Compare.py +180 -0
- DefEssential/Validator/Patterns/Condition.py +60 -0
- DefEssential/Validator/Patterns/DataObject.py +85 -0
- DefEssential/Validator/Patterns/DateTime.py +111 -0
- DefEssential/Validator/Patterns/Number.py +43 -0
- DefEssential/Validator/Patterns/Object.py +150 -0
- DefEssential/Validator/Patterns/Size.py +110 -0
- DefEssential/Validator/Patterns/String.py +85 -0
- DefEssential/Validator/Patterns/Type.py +350 -0
- DefEssential/Validator/Patterns/__init__.py +137 -0
- DefEssential/Validator/Validate.py +29 -0
- DefEssential/Validator/Validator.py +30 -0
- DefEssential/Validator/__init__.py +11 -0
- DefEssential/Validators.py +255 -0
- DefEssential/__init__.py +83 -0
- defessential-0.1.8.dist-info/METADATA +86 -0
- defessential-0.1.8.dist-info/RECORD +104 -0
- defessential-0.1.8.dist-info/WHEEL +6 -0
- defessential-0.1.8.dist-info/licenses/LICENSE +674 -0
- 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
|