apitally 0.13.0a1__py3-none-any.whl → 0.14.0rc1__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.
apitally/django.py CHANGED
@@ -13,10 +13,15 @@ from django.conf import settings
13
13
  from django.urls import URLPattern, URLResolver, get_resolver
14
14
  from django.utils.module_loading import import_string
15
15
 
16
- from apitally.client.base import Consumer as ApitallyConsumer
16
+ from apitally.client.client_threading import ApitallyClient
17
+ from apitally.client.consumers import Consumer as ApitallyConsumer
17
18
  from apitally.client.logging import get_logger
18
- from apitally.client.threading import ApitallyClient
19
- from apitally.common import get_versions
19
+ from apitally.client.request_logging import (
20
+ BODY_TOO_LARGE,
21
+ MAX_BODY_SIZE,
22
+ RequestLoggingConfig,
23
+ )
24
+ from apitally.common import get_versions, parse_int
20
25
 
21
26
 
22
27
  if TYPE_CHECKING:
@@ -24,7 +29,7 @@ if TYPE_CHECKING:
24
29
  from ninja import NinjaAPI
25
30
 
26
31
 
27
- __all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
32
+ __all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
28
33
  logger = get_logger(__name__)
29
34
 
30
35
 
@@ -32,6 +37,7 @@ logger = get_logger(__name__)
32
37
  class ApitallyMiddlewareConfig:
33
38
  client_id: str
34
39
  env: str
40
+ request_logging_config: Optional[RequestLoggingConfig]
35
41
  app_version: Optional[str]
36
42
  identify_consumer_callback: Optional[Callable[[HttpRequest], Union[str, ApitallyConsumer, None]]]
37
43
  urlconfs: List[Optional[str]]
@@ -61,7 +67,11 @@ class ApitallyMiddleware:
61
67
  if self.ninja_available and None not in self.config.urlconfs:
62
68
  self.callbacks.update(_get_ninja_callbacks(self.config.urlconfs))
63
69
 
64
- self.client = ApitallyClient(client_id=self.config.client_id, env=self.config.env)
70
+ self.client = ApitallyClient(
71
+ client_id=self.config.client_id,
72
+ env=self.config.env,
73
+ request_logging_config=self.config.request_logging_config,
74
+ )
65
75
  self.client.start_sync_loop()
66
76
  self.client.set_startup_data(
67
77
  _get_startup_data(
@@ -70,11 +80,19 @@ class ApitallyMiddleware:
70
80
  )
71
81
  )
72
82
 
83
+ self.capture_request_body = (
84
+ self.client.request_logger.config.enabled and self.client.request_logger.config.log_request_body
85
+ )
86
+ self.capture_response_body = (
87
+ self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
88
+ )
89
+
73
90
  @classmethod
74
91
  def configure(
75
92
  cls,
76
93
  client_id: str,
77
94
  env: str = "dev",
95
+ request_logging_config: Optional[RequestLoggingConfig] = None,
78
96
  app_version: Optional[str] = None,
79
97
  identify_consumer_callback: Optional[str] = None,
80
98
  urlconf: Optional[Union[List[Optional[str]], str]] = None,
@@ -82,6 +100,7 @@ class ApitallyMiddleware:
82
100
  cls.config = ApitallyMiddlewareConfig(
83
101
  client_id=client_id,
84
102
  env=env,
103
+ request_logging_config=request_logging_config,
85
104
  app_version=app_version,
86
105
  identify_consumer_callback=import_string(identify_consumer_callback)
87
106
  if identify_consumer_callback
@@ -90,11 +109,31 @@ class ApitallyMiddleware:
90
109
  )
91
110
 
92
111
  def __call__(self, request: HttpRequest) -> HttpResponse:
93
- start_time = time.perf_counter()
94
- response = self.get_response(request)
95
- response_time = time.perf_counter() - start_time
96
- path = self.get_path(request)
97
- if request.method is not None and request.method != "OPTIONS" and path is not None:
112
+ if request.method is not None and request.method != "OPTIONS":
113
+ timestamp = time.time()
114
+ request_size = parse_int(request.headers.get("Content-Length"))
115
+ request_body = b""
116
+ if self.capture_request_body:
117
+ request_body = (
118
+ request.body
119
+ if request_size is not None and request_size <= MAX_BODY_SIZE and len(request.body) <= MAX_BODY_SIZE
120
+ else BODY_TOO_LARGE
121
+ )
122
+
123
+ start_time = time.perf_counter()
124
+ response = self.get_response(request)
125
+ response_time = time.perf_counter() - start_time
126
+ response_size = (
127
+ parse_int(response["Content-Length"])
128
+ if response.has_header("Content-Length")
129
+ else (len(response.content) if not response.streaming else None)
130
+ )
131
+ response_body = b""
132
+ if self.capture_response_body and not response.streaming:
133
+ response_body = (
134
+ response.content if response_size is not None and response_size <= MAX_BODY_SIZE else BODY_TOO_LARGE
135
+ )
136
+
98
137
  try:
99
138
  consumer = self.get_consumer(request)
100
139
  consumer_identifier = consumer.identifier if consumer else None
@@ -102,48 +141,75 @@ class ApitallyMiddleware:
102
141
  except Exception: # pragma: no cover
103
142
  logger.exception("Failed to get consumer for request")
104
143
  consumer_identifier = None
105
- try:
106
- self.client.request_counter.add_request(
107
- consumer=consumer_identifier,
108
- method=request.method,
109
- path=path,
110
- status_code=response.status_code,
111
- response_time=response_time,
112
- request_size=request.headers.get("Content-Length"),
113
- response_size=response["Content-Length"]
114
- if response.has_header("Content-Length")
115
- else (len(response.content) if not response.streaming else None),
116
- )
117
- except Exception: # pragma: no cover
118
- logger.exception("Failed to log request metadata")
119
- if (
120
- response.status_code == 422
121
- and (content_type := response.get("Content-Type")) is not None
122
- and content_type.startswith("application/json")
123
- ):
124
- try:
125
- with contextlib.suppress(json.JSONDecodeError):
126
- body = json.loads(response.content)
127
- if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
128
- # Log Django Ninja / Pydantic validation errors
129
- self.client.validation_error_counter.add_validation_errors(
130
- consumer=consumer_identifier,
131
- method=request.method,
132
- path=path,
133
- detail=body["detail"],
134
- )
135
- except Exception: # pragma: no cover
136
- logger.exception("Failed to log validation errors")
137
- if response.status_code == 500 and hasattr(request, "unhandled_exception"):
144
+
145
+ path = self.get_path(request)
146
+ if path is not None:
138
147
  try:
139
- self.client.server_error_counter.add_server_error(
148
+ self.client.request_counter.add_request(
140
149
  consumer=consumer_identifier,
141
150
  method=request.method,
142
151
  path=path,
143
- exception=getattr(request, "unhandled_exception"),
152
+ status_code=response.status_code,
153
+ response_time=response_time,
154
+ request_size=request_size,
155
+ response_size=response_size,
144
156
  )
145
157
  except Exception: # pragma: no cover
146
- logger.exception("Failed to log server error")
158
+ logger.exception("Failed to log request metadata")
159
+
160
+ if (
161
+ response.status_code == 422
162
+ and (content_type := response.get("Content-Type")) is not None
163
+ and content_type.startswith("application/json")
164
+ ):
165
+ try:
166
+ with contextlib.suppress(json.JSONDecodeError):
167
+ body = json.loads(response.content)
168
+ if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
169
+ # Log Django Ninja / Pydantic validation errors
170
+ self.client.validation_error_counter.add_validation_errors(
171
+ consumer=consumer_identifier,
172
+ method=request.method,
173
+ path=path,
174
+ detail=body["detail"],
175
+ )
176
+ except Exception: # pragma: no cover
177
+ logger.exception("Failed to log validation errors")
178
+
179
+ if response.status_code == 500 and hasattr(request, "unhandled_exception"):
180
+ try:
181
+ self.client.server_error_counter.add_server_error(
182
+ consumer=consumer_identifier,
183
+ method=request.method,
184
+ path=path,
185
+ exception=getattr(request, "unhandled_exception"),
186
+ )
187
+ except Exception: # pragma: no cover
188
+ logger.exception("Failed to log server error")
189
+
190
+ if self.client.request_logger.enabled:
191
+ self.client.request_logger.log_request(
192
+ request={
193
+ "timestamp": timestamp,
194
+ "method": request.method,
195
+ "path": path,
196
+ "url": request.build_absolute_uri(),
197
+ "headers": list(request.headers.items()),
198
+ "size": request_size,
199
+ "consumer": consumer_identifier,
200
+ "body": request_body,
201
+ },
202
+ response={
203
+ "status_code": response.status_code,
204
+ "response_time": response_time,
205
+ "headers": list(response.items()),
206
+ "size": response_size,
207
+ "body": response_body,
208
+ },
209
+ )
210
+ else:
211
+ response = self.get_response(request)
212
+
147
213
  return response
148
214
 
149
215
  def process_exception(self, request: HttpRequest, exception: Exception) -> None:
apitally/django_ninja.py CHANGED
@@ -1,4 +1,4 @@
1
- from apitally.django import ApitallyConsumer, ApitallyMiddleware
1
+ from apitally.django import ApitallyConsumer, ApitallyMiddleware, RequestLoggingConfig
2
2
 
3
3
 
4
- __all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
4
+ __all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
@@ -1,4 +1,4 @@
1
- from apitally.django import ApitallyConsumer, ApitallyMiddleware
1
+ from apitally.django import ApitallyConsumer, ApitallyMiddleware, RequestLoggingConfig
2
2
 
3
3
 
4
- __all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
4
+ __all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
apitally/fastapi.py CHANGED
@@ -1,4 +1,4 @@
1
- from apitally.starlette import ApitallyConsumer, ApitallyMiddleware
1
+ from apitally.starlette import ApitallyConsumer, ApitallyMiddleware, RequestLoggingConfig
2
2
 
3
3
 
4
- __all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
4
+ __all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
apitally/flask.py CHANGED
@@ -1,18 +1,24 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import time
4
+ from io import BytesIO
4
5
  from threading import Timer
5
6
  from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
6
7
  from warnings import warn
7
8
 
8
9
  from flask import Flask, g
9
- from flask.wrappers import Response
10
+ from flask.wrappers import Request, Response
10
11
  from werkzeug.datastructures import Headers
11
12
  from werkzeug.exceptions import NotFound
12
13
  from werkzeug.test import Client
13
14
 
14
- from apitally.client.base import Consumer as ApitallyConsumer
15
- from apitally.client.threading import ApitallyClient
15
+ from apitally.client.client_threading import ApitallyClient
16
+ from apitally.client.consumers import Consumer as ApitallyConsumer
17
+ from apitally.client.request_logging import (
18
+ BODY_TOO_LARGE,
19
+ MAX_BODY_SIZE,
20
+ RequestLoggingConfig,
21
+ )
16
22
  from apitally.common import get_versions
17
23
 
18
24
 
@@ -21,7 +27,7 @@ if TYPE_CHECKING:
21
27
  from werkzeug.routing.map import Map
22
28
 
23
29
 
24
- __all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
30
+ __all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
25
31
 
26
32
 
27
33
  class ApitallyMiddleware:
@@ -30,18 +36,24 @@ class ApitallyMiddleware:
30
36
  app: Flask,
31
37
  client_id: str,
32
38
  env: str = "dev",
39
+ request_logging_config: Optional[RequestLoggingConfig] = None,
33
40
  app_version: Optional[str] = None,
34
41
  openapi_url: Optional[str] = None,
35
- filter_unhandled_paths: bool = True,
36
42
  ) -> None:
37
43
  self.app = app
38
44
  self.wsgi_app = app.wsgi_app
39
- self.filter_unhandled_paths = filter_unhandled_paths
40
45
  self.patch_handle_exception()
41
- self.client = ApitallyClient(client_id=client_id, env=env)
46
+ self.client = ApitallyClient(client_id=client_id, env=env, request_logging_config=request_logging_config)
42
47
  self.client.start_sync_loop()
43
48
  self.delayed_set_startup_data(app_version, openapi_url)
44
49
 
50
+ self.capture_request_body = (
51
+ self.client.request_logger.config.enabled and self.client.request_logger.config.log_request_body
52
+ )
53
+ self.capture_response_body = (
54
+ self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
55
+ )
56
+
45
57
  def delayed_set_startup_data(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
46
58
  # Short delay to allow app routes to be registered first
47
59
  timer = Timer(
@@ -56,8 +68,9 @@ class ApitallyMiddleware:
56
68
  self.client.set_startup_data(data)
57
69
 
58
70
  def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
59
- status_code = 200
71
+ timestamp = time.time()
60
72
  response_headers = Headers([])
73
+ status_code = 0
61
74
 
62
75
  def catching_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None):
63
76
  nonlocal status_code, response_headers
@@ -65,14 +78,41 @@ class ApitallyMiddleware:
65
78
  response_headers = Headers(headers)
66
79
  return start_response(status, headers, exc_info)
67
80
 
68
- start_time = time.perf_counter()
69
81
  with self.app.app_context():
82
+ request = Request(environ, populate_request=False, shallow=True)
83
+ request_size = request.content_length
84
+ request_body = b""
85
+ if self.capture_request_body:
86
+ request_body = (
87
+ _read_request_body(environ)
88
+ if request_size is not None and request_size <= MAX_BODY_SIZE
89
+ else BODY_TOO_LARGE
90
+ )
91
+
92
+ start_time = time.perf_counter()
70
93
  response = self.wsgi_app(environ, catching_start_response)
94
+ response_time = time.perf_counter() - start_time
95
+
96
+ response_body = b""
97
+ if self.capture_response_body:
98
+ response_size = response_headers.get("Content-Length", type=int)
99
+ if response_size is not None and response_size > MAX_BODY_SIZE:
100
+ response_body = BODY_TOO_LARGE
101
+ else:
102
+ for chunk in response:
103
+ response_body += chunk
104
+ if len(response_body) > MAX_BODY_SIZE:
105
+ response_body = BODY_TOO_LARGE
106
+ break
107
+
71
108
  self.add_request(
72
- environ=environ,
109
+ timestamp=timestamp,
110
+ request=request,
111
+ request_body=request_body,
73
112
  status_code=status_code,
74
- response_time=time.perf_counter() - start_time,
113
+ response_time=response_time,
75
114
  response_headers=response_headers,
115
+ response_body=response_body,
76
116
  )
77
117
  return response
78
118
 
@@ -87,41 +127,68 @@ class ApitallyMiddleware:
87
127
 
88
128
  def add_request(
89
129
  self,
90
- environ: WSGIEnvironment,
130
+ timestamp: float,
131
+ request: Request,
132
+ request_body: bytes,
91
133
  status_code: int,
92
134
  response_time: float,
93
135
  response_headers: Headers,
136
+ response_body: bytes,
94
137
  ) -> None:
95
- rule, is_handled_path = self.get_rule(environ)
96
- if (is_handled_path or not self.filter_unhandled_paths) and environ["REQUEST_METHOD"] != "OPTIONS":
97
- consumer = self.get_consumer()
98
- consumer_identifier = consumer.identifier if consumer else None
99
- self.client.consumer_registry.add_or_update_consumer(consumer)
138
+ path = self.get_path(request.environ)
139
+ response_size = response_headers.get("Content-Length", type=int)
140
+
141
+ consumer = self.get_consumer()
142
+ consumer_identifier = consumer.identifier if consumer else None
143
+ self.client.consumer_registry.add_or_update_consumer(consumer)
144
+
145
+ if path is not None and request.method != "OPTIONS":
100
146
  self.client.request_counter.add_request(
101
147
  consumer=consumer_identifier,
102
- method=environ["REQUEST_METHOD"],
103
- path=rule,
148
+ method=request.method,
149
+ path=path,
104
150
  status_code=status_code,
105
151
  response_time=response_time,
106
- request_size=environ.get("CONTENT_LENGTH"),
107
- response_size=response_headers.get("Content-Length", type=int),
152
+ request_size=request.content_length,
153
+ response_size=response_size,
108
154
  )
109
155
  if status_code == 500 and "unhandled_exception" in g:
110
156
  self.client.server_error_counter.add_server_error(
111
157
  consumer=consumer_identifier,
112
- method=environ["REQUEST_METHOD"],
113
- path=rule,
158
+ method=request.method,
159
+ path=path,
114
160
  exception=g.unhandled_exception,
115
161
  )
116
162
 
117
- def get_rule(self, environ: WSGIEnvironment) -> Tuple[str, bool]:
163
+ if self.client.request_logger.enabled:
164
+ self.client.request_logger.log_request(
165
+ request={
166
+ "timestamp": timestamp,
167
+ "method": request.method,
168
+ "path": path,
169
+ "url": request.url,
170
+ "headers": list(request.headers.items()),
171
+ "size": request.content_length,
172
+ "consumer": consumer_identifier,
173
+ "body": request_body,
174
+ },
175
+ response={
176
+ "status_code": status_code,
177
+ "response_time": response_time,
178
+ "headers": list(response_headers.items()),
179
+ "size": response_size,
180
+ "body": response_body,
181
+ },
182
+ )
183
+
184
+ def get_path(self, environ: WSGIEnvironment) -> Optional[str]:
118
185
  url_adapter = self.app.url_map.bind_to_environ(environ)
119
186
  try:
120
187
  endpoint, _ = url_adapter.match()
121
188
  rule = self.app.url_map._rules_by_endpoint[endpoint][0]
122
- return rule.rule, True
189
+ return rule.rule
123
190
  except NotFound:
124
- return environ["PATH_INFO"], False
191
+ return None
125
192
 
126
193
  def get_consumer(self) -> Optional[ApitallyConsumer]:
127
194
  if "apitally_consumer" in g and g.apitally_consumer:
@@ -166,3 +233,10 @@ def _get_openapi(app: WSGIApplication, openapi_url: str) -> Optional[str]:
166
233
  if response.status_code != 200:
167
234
  return None
168
235
  return response.get_data(as_text=True)
236
+
237
+
238
+ def _read_request_body(environ: WSGIEnvironment) -> bytes:
239
+ length = int(environ.get("CONTENT_LENGTH", "0"))
240
+ body = environ["wsgi.input"].read(length)
241
+ environ["wsgi.input"] = BytesIO(body)
242
+ return body