Skip to content

Commit cd2ef5c

Browse files
committed
feat: when APPMAP_RECORD_REQUESTS is set record each request in a separate file
1 parent bde25fb commit cd2ef5c

File tree

13 files changed

+539
-89
lines changed

13 files changed

+539
-89
lines changed

appmap/_implementation/env.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ def enabled(self, value):
7676
def display_params(self):
7777
return self.get("APPMAP_DISPLAY_PARAMS", "true").lower() == "true"
7878

79+
@property
80+
def record_all_requests(self):
81+
return self.get("APPMAP_RECORD_REQUESTS", "false").lower() == "true"
82+
7983
def _configure_logging(self):
8084
log_level = self.get("APPMAP_LOG_LEVEL", "warning").upper()
8185

appmap/_implementation/testing_framework.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Shared infrastructure for testing framework integration."""
22

3+
import datetime
34
import os
5+
import os.path
46
import re
57
from contextlib import contextmanager
68
from hashlib import sha256
@@ -9,7 +11,8 @@
911
import inflection
1012

1113
from appmap._implementation import configuration, env, generation, recording
12-
from appmap._implementation.utils import fqname
14+
from appmap._implementation.env import Env
15+
from appmap._implementation.utils import fqname, scenario_filename
1316

1417
from .metadata import Metadata
1518

@@ -120,6 +123,34 @@ def write_appmap(basedir, basename, contents):
120123
tmp.write(contents)
121124
os.replace(tmp.name, basedir / filename)
122125

126+
def create_appmap_file(request_method, request_path_info, request_full_path, response, headers, rec):
127+
start_time = datetime.datetime.now()
128+
appmap_name = (
129+
request_method
130+
+ " "
131+
+ request_path_info
132+
+ " ("
133+
+ str(response.status_code)
134+
+ ") - "
135+
+ start_time.strftime("%T.%f")[:-3]
136+
)
137+
output_dir = Env.current.output_dir
138+
appmap_basename = scenario_filename(
139+
"_".join([str(start_time.timestamp()), request_full_path])
140+
)
141+
appmap_file_path = os.path.join(output_dir, appmap_basename)
142+
metadata = {
143+
"name": appmap_name,
144+
"timestamp": start_time.timestamp(),
145+
"recorder": {"name": "record_requests"},
146+
}
147+
write_appmap(
148+
output_dir, appmap_basename, generation.dump(rec, metadata)
149+
)
150+
headers["AppMap-Name"] = os.path.abspath(appmap_name)
151+
headers["AppMap-File-Name"] = (
152+
os.path.abspath(appmap_file_path) + APPMAP_SUFFIX
153+
)
123154

124155
class session:
125156
def __init__(self, name, recorder_type, version=None):

appmap/_implementation/utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import inspect
22
import os
3+
import re
34
import shlex
45
import subprocess
56
import threading
@@ -192,3 +193,10 @@ def _wrap_cls(patch):
192193
return patch
193194

194195
return _wrap_cls
196+
197+
198+
# this is different than appmap-ruby: part of its logic is in write_appmap
199+
def scenario_filename(name, separator="-"):
200+
pattern = r"[^a-z0-9\-_]+"
201+
replacement = separator
202+
return re.sub(pattern, replacement, name, flags=re.IGNORECASE)

appmap/django.py

Lines changed: 76 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@
2222
from django.urls.exceptions import Resolver404
2323
from django.urls.resolvers import _route_to_regex
2424

25-
from appmap._implementation import generation
25+
from appmap._implementation import generation, recording, testing_framework
2626
from appmap._implementation.env import Env
2727
from appmap._implementation.event import (
2828
ExceptionEvent,
2929
HttpServerRequestEvent,
3030
HttpServerResponseEvent,
3131
ReturnEvent,
3232
SqlEvent,
33+
_EventIds,
3334
)
3435
from appmap._implementation.instrument import is_instrumentation_disabled
3536
from appmap._implementation.recording import Recorder
@@ -218,51 +219,32 @@ def __call__(self, request):
218219

219220
if request.path_info == "/_appmap/record":
220221
return self.recording(request)
221-
if self.recorder.enabled:
222-
add_metadata()
223-
start = time.monotonic()
224-
params = request_params(request)
225-
try:
226-
resolved = resolve(request.path_info)
227-
params.update(resolved.kwargs)
228-
normalized_path_info = normalize_path_info(request.path_info, resolved)
229-
except Resolver404:
230-
# If the request was for a bad path (e.g. when an app
231-
# is testing 404 handling), resolving will fail.
232-
normalized_path_info = None
233-
234-
call_event = HttpServerRequestEvent(
235-
request_method=request.method,
236-
path_info=request.path_info,
237-
message_parameters=params,
238-
normalized_path_info=normalized_path_info,
239-
protocol=request.META["SERVER_PROTOCOL"],
240-
headers=request.headers,
241-
)
242-
Recorder.add_event(call_event)
243222

244-
try:
245-
response = self.get_response(request)
246-
except:
247-
if self.recorder.enabled:
248-
duration = time.monotonic() - start
249-
exception_event = ExceptionEvent(
250-
parent_id=call_event.id, elapsed=duration, exc_info=sys.exc_info()
251-
)
252-
Recorder.add_event(exception_event)
253-
raise
254-
255-
if self.recorder.enabled:
256-
duration = time.monotonic() - start
257-
return_event = HttpServerResponseEvent(
258-
parent_id=call_event.id,
259-
elapsed=duration,
260-
status_code=response.status_code,
261-
headers=dict(response.items()),
262-
)
263-
Recorder.add_event(return_event)
264-
265-
return response
223+
if Env.current.enabled or self.recorder.enabled:
224+
# It should be recording or it's currently recording. The
225+
# recording is either
226+
# a) remote, enabled by POST to /_appmap/record, which set
227+
# self.recorder.enabled, or
228+
# b) requests, set by Env.current.record_all_requests, or
229+
# c) both remote and requests; there are multiple active recorders.
230+
if not Env.current.record_all_requests and self.recorder.enabled:
231+
# a)
232+
return self.record_request([self.recorder], request)
233+
elif Env.current.record_all_requests:
234+
# b) or c)
235+
rec = Recorder(_EventIds.get_thread_id())
236+
rec.start_recording()
237+
recorders = [rec]
238+
# Each time an event is added for a thread_id it's
239+
# also added to the global Recorder(). So don't add
240+
# the global Recorder() into recorders: that would
241+
# have added the event in the global Recorder() twice.
242+
try:
243+
response = self.record_request(recorders, request)
244+
testing_framework.create_appmap_file(request.method, request.path_info, request.get_full_path(), response, response, rec)
245+
return response
246+
finally:
247+
rec.stop_recording()
266248

267249
def recording(self, request):
268250
"""Handle recording requests."""
@@ -286,6 +268,55 @@ def recording(self, request):
286268

287269
return HttpResponseBadRequest()
288270

271+
def record_request(self, recorders, request):
272+
for rec in recorders:
273+
if rec.enabled:
274+
add_metadata()
275+
start = time.monotonic()
276+
params = request_params(request)
277+
try:
278+
resolved = resolve(request.path_info)
279+
params.update(resolved.kwargs)
280+
normalized_path_info = normalize_path_info(request.path_info, resolved)
281+
except Resolver404:
282+
# If the request was for a bad path (e.g. when an app
283+
# is testing 404 handling), resolving will fail.
284+
normalized_path_info = None
285+
286+
call_event = HttpServerRequestEvent(
287+
request_method=request.method,
288+
path_info=request.path_info,
289+
message_parameters=params,
290+
normalized_path_info=normalized_path_info,
291+
protocol=request.META["SERVER_PROTOCOL"],
292+
headers=request.headers,
293+
)
294+
rec.add_event(call_event)
295+
296+
try:
297+
response = self.get_response(request)
298+
except:
299+
for rec in recorders:
300+
if rec and rec.enabled:
301+
duration = time.monotonic() - start
302+
exception_event = ExceptionEvent(
303+
parent_id=call_event.id, elapsed=duration, exc_info=sys.exc_info()
304+
)
305+
rec.add_event(exception_event)
306+
raise
307+
308+
for rec in recorders:
309+
if rec and rec.enabled:
310+
duration = time.monotonic() - start
311+
return_event = HttpServerResponseEvent(
312+
parent_id=call_event.id,
313+
elapsed=duration,
314+
status_code=response.status_code,
315+
headers=dict(response.items()),
316+
)
317+
rec.add_event(return_event)
318+
319+
return response
289320

290321
def inject_middleware():
291322
"""Make sure AppMap middleware is added to the stack"""

appmap/flask.py

Lines changed: 100 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import datetime
12
import json
3+
import os.path
24
import time
35
from functools import wraps
46

@@ -9,9 +11,9 @@
911
from werkzeug.exceptions import BadRequest
1012
from werkzeug.routing import parse_rule
1113

12-
from appmap._implementation import generation
14+
from appmap._implementation import generation, testing_framework
1315
from appmap._implementation.env import Env
14-
from appmap._implementation.event import HttpServerRequestEvent, HttpServerResponseEvent
16+
from appmap._implementation.event import HttpServerRequestEvent, HttpServerResponseEvent, _EventIds
1517
from appmap._implementation.recording import Recorder, Recording
1618
from appmap._implementation.web_framework import TemplateHandler as BaseTemplateHandler
1719

@@ -100,49 +102,108 @@ def record_delete(self):
100102
return json.loads(generation.dump(self.recording))
101103

102104
def before_request(self):
103-
if self.recording.is_running() and request.path != self.record_url:
104-
Metadata.add_framework("flask", flask.__version__)
105-
np = None
106-
# See
107-
# https://github.com/pallets/werkzeug/blob/2.0.0/src/werkzeug/routing.py#L213
108-
# for a description of parse_rule.
109-
if request.url_rule:
110-
np = "".join(
111-
[
112-
f"{{{p}}}" if c else p
113-
for c, _, p in parse_rule(request.url_rule.rule)
114-
]
105+
if request.path == self.record_url:
106+
return
107+
108+
if (Env.current.enabled or self.recording.is_running()):
109+
# It should be recording or it's currently recording. The
110+
# recording is either
111+
# a) remote, enabled by POST to /_appmap/record, which set
112+
# self.recording.is_running, or
113+
# b) requests, set by Env.current.record_all_requests, or
114+
# c) both remote and requests; there are multiple active recorders.
115+
if not Env.current.record_all_requests and self.recording.is_running():
116+
self.before_request_main([Recorder()])
117+
else:
118+
rec = Recorder(_EventIds.get_thread_id())
119+
rec.start_recording()
120+
recorders = [rec]
121+
# Each time an event is added for a thread_id it's
122+
# also added to the global Recorder(). So don't add
123+
# the global Recorder() into recorders: that would
124+
# have added the event in the global Recorder() twice.
125+
self.before_request_main(recorders)
126+
127+
def before_request_main(self, recorders):
128+
for rec in recorders:
129+
if rec.enabled:
130+
Metadata.add_framework("flask", flask.__version__)
131+
np = None
132+
# See
133+
# https://github.com/pallets/werkzeug/blob/2.0.0/src/werkzeug/routing.py#L213
134+
# for a description of parse_rule.
135+
if request.url_rule:
136+
np = "".join(
137+
[
138+
f"{{{p}}}" if c else p
139+
for c, _, p in parse_rule(request.url_rule.rule)
140+
]
141+
)
142+
call_event = HttpServerRequestEvent(
143+
request_method=request.method,
144+
path_info=request.path,
145+
message_parameters=request_params(request),
146+
normalized_path_info=np,
147+
protocol=request.environ.get("SERVER_PROTOCOL"),
148+
headers=request.headers,
115149
)
116-
call_event = HttpServerRequestEvent(
117-
request_method=request.method,
118-
path_info=request.path,
119-
message_parameters=request_params(request),
120-
normalized_path_info=np,
121-
protocol=request.environ.get("SERVER_PROTOCOL"),
122-
headers=request.headers,
123-
)
124-
Recorder.add_event(call_event)
125-
126-
appctx = _app_ctx_stack.top
127-
appctx.appmap_request_event = call_event
128-
appctx.appmap_request_start = time.monotonic()
150+
rec.add_event(call_event)
151+
152+
appctx = _app_ctx_stack.top
153+
appctx.appmap_request_event = call_event
154+
appctx.appmap_request_start = time.monotonic()
129155

130156
def after_request(self, response):
131-
if self.recording.is_running() and request.path != self.record_url:
132-
appctx = _app_ctx_stack.top
133-
parent_id = appctx.appmap_request_event.id
134-
duration = time.monotonic() - appctx.appmap_request_start
135-
136-
return_event = HttpServerResponseEvent(
137-
parent_id=parent_id,
138-
elapsed=duration,
139-
status_code=response.status_code,
140-
headers=response.headers,
141-
)
142-
Recorder.add_event(return_event)
157+
if request.path == self.record_url:
158+
return response
159+
160+
if Env.current.enabled or self.recording.is_running():
161+
# It should be recording or it's currently recording. The
162+
# recording is either
163+
# a) remote, enabled by POST to /_appmap/record, which set
164+
# self.recording.is_running, or
165+
# b) requests, set by Env.current.record_all_requests, or
166+
# c) both remote and requests; there are multiple active recorders.
167+
if not Env.current.record_all_requests and self.recording.is_running():
168+
# a)
169+
self.after_request_main([Recorder()], response)
170+
else:
171+
# b) or c)
172+
rec = Recorder(_EventIds.get_thread_id())
173+
recorders = [rec]
174+
# Each time an event is added for a thread_id it's
175+
# also added to the global Recorder(). So don't add
176+
# the global Recorder() into recorders: that would
177+
# have added the event in the global Recorder() twice.
178+
try:
179+
self.after_request_main(recorders, response)
180+
web_framework.create_appmap_file(
181+
request.method,
182+
request.path,
183+
request.path,
184+
response,
185+
response.headers,
186+
rec,
187+
)
188+
finally:
189+
rec.stop_recording()
143190

144191
return response
145192

193+
def after_request_main(self, recorders, response):
194+
for rec in recorders:
195+
if rec.enabled:
196+
appctx = _app_ctx_stack.top
197+
parent_id = appctx.appmap_request_event.id
198+
duration = time.monotonic() - appctx.appmap_request_start
199+
200+
return_event = HttpServerResponseEvent(
201+
parent_id=parent_id,
202+
elapsed=duration,
203+
status_code=response.status_code,
204+
headers=response.headers,
205+
)
206+
rec.add_event(return_event)
146207

147208
@patch_class(jinja2.Template)
148209
class TemplateHandler(BaseTemplateHandler):

0 commit comments

Comments
 (0)