Skip to content

Commit 37fcb7c

Browse files
authored
Merge pull request #287 from getappmap/fastapi_20240227
Add FastAPI support
2 parents ac88299 + 3da004f commit 37fcb7c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+1029
-465
lines changed

_appmap/configuration.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
Manage Configuration AppMap recorder for Python.
33
"""
44

5+
import importlib.metadata
56
import inspect
67
import os
78
import sys
89
from os.path import realpath
910
from pathlib import Path
1011
from textwrap import dedent
1112

12-
import importlib_metadata
1313
import yaml
1414
from yaml.parser import ParserError
1515

@@ -142,8 +142,6 @@ def __init__(self):
142142

143143
self._load_config()
144144
self._load_functions()
145-
logger.info("config: %s", self._config)
146-
logger.debug("package_functions: %s", self.package_functions)
147145

148146
if "labels" in self._config:
149147
self.labels.append(self._config["labels"])
@@ -314,7 +312,7 @@ class DistMatcher(PathMatcher):
314312
def __init__(self, dist, *args, **kwargs):
315313
super().__init__(*args, **kwargs)
316314
self.dist = dist
317-
self.files = [str(pp.locate()) for pp in importlib_metadata.files(dist)]
315+
self.files = [str(pp.locate()) for pp in importlib.metadata.files(dist)]
318316

319317
def matches(self, filterable):
320318
try:
@@ -415,3 +413,8 @@ def initialize():
415413

416414

417415
initialize()
416+
417+
c = Config()
418+
logger.info("config: %s", c._config)
419+
logger.debug("package_functions: %s", c.package_functions)
420+
logger.info("env: %r", os.environ)

_appmap/env.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging.config
55
import os
66
from contextlib import contextmanager
7+
from datetime import datetime
78
from os import environ
89
from pathlib import Path
910
from typing import cast
@@ -124,11 +125,10 @@ def getLogger(self, name) -> trace_logger.TraceLogger:
124125
def _configure_logging(self):
125126
trace_logger.install()
126127

127-
log_level = self.get("APPMAP_LOG_LEVEL", "warning").upper()
128-
128+
log_level = self.get("APPMAP_LOG_LEVEL", "warn").upper()
129+
disable_log = os.environ.get("APPMAP_DISABLE_LOG_FILE", "false").upper() != "FALSE"
129130
log_config = self.get("APPMAP_LOG_CONFIG")
130-
log_stream = self.get("APPMAP_LOG_STREAM", "stderr")
131-
log_stream = "ext://sys.%s" % (log_stream)
131+
now = datetime.now()
132132
config_dict = {
133133
"version": 1,
134134
"disable_existing_loggers": False,
@@ -138,9 +138,7 @@ def _configure_logging(self):
138138
"format": "[{asctime}] {levelname} {name}: {message}",
139139
}
140140
},
141-
"handlers": {
142-
"default": {"class": "logging.StreamHandler", "formatter": "default"}
143-
},
141+
"handlers": {"default": {"class": "logging.StreamHandler", "formatter": "default"}},
144142
"loggers": {
145143
"appmap": {
146144
"level": log_level,
@@ -154,6 +152,20 @@ def _configure_logging(self):
154152
},
155153
},
156154
}
155+
if not disable_log:
156+
# Default to being more verbose if we're logging to a file, but
157+
# still allow the level to be overridden.
158+
log_level = self.get("APPMAP_LOG_LEVEL", "info").upper()
159+
loggers = config_dict["loggers"]
160+
loggers["appmap"]["level"] = loggers["_appmap"]["level"] = log_level
161+
config_dict["handlers"] = {
162+
"default": {
163+
"class": "logging.FileHandler",
164+
"formatter": "default",
165+
"filename": f"appmap-{now:%Y%m%d%H%M%S}-{os.getpid()}.log",
166+
}
167+
}
168+
157169
if log_config is not None:
158170
name, level = log_config.split("=", 2)
159171
config_dict["loggers"].update(

_appmap/event.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
from .recorder import Recorder
1111
from .utils import (
1212
FnType,
13+
FqFnName,
1314
appmap_tls,
1415
compact_dict,
1516
fqname,
1617
get_function_location,
17-
split_function_name,
1818
)
1919

2020
logger = Env.current.getLogger(__name__)
@@ -173,7 +173,7 @@ def to_dict(self, value):
173173

174174

175175
class CallEvent(Event):
176-
__slots__ = ["_fn", "static", "receiver", "parameters", "labels"]
176+
__slots__ = ["_fn", "_fqfn", "static", "receiver", "parameters", "labels"]
177177

178178
@staticmethod
179179
def make(fn, fntype):
@@ -209,10 +209,9 @@ def make_params(filterable):
209209
# going to log a message about a mismatch.
210210
wrapped_sig = inspect.signature(fn, follow_wrapped=True)
211211
if sig != wrapped_sig:
212-
logger.debug(
213-
"signature of wrapper %s.%s doesn't match wrapped",
214-
*split_function_name(fn)
215-
)
212+
logger.debug("signature of wrapper %r doesn't match wrapped", fn)
213+
logger.debug("sig: %r", sig)
214+
logger.debug("wrapped_sig: %r", wrapped_sig)
216215

217216
return [Param(p) for p in sig.parameters.values()]
218217

@@ -270,17 +269,17 @@ def set_params(params, instance, args, kwargs):
270269
@property
271270
@lru_cache(maxsize=None)
272271
def function_name(self):
273-
return split_function_name(self._fn)
272+
return self._fqfn.fqfn
274273

275274
@property
276275
@lru_cache(maxsize=None)
277276
def defined_class(self):
278-
return self.function_name[0]
277+
return self._fqfn.fqclass
279278

280279
@property
281280
@lru_cache(maxsize=None)
282281
def method_id(self):
283-
return self.function_name[1]
282+
return self._fqfn.fqfn[1]
284283

285284
@property
286285
@lru_cache(maxsize=None)
@@ -308,6 +307,7 @@ def comment(self):
308307
def __init__(self, fn, fntype, parameters, labels):
309308
super().__init__("call")
310309
self._fn = fn
310+
self._fqfn = FqFnName(fn)
311311
self.static = fntype in FnType.STATIC | FnType.CLASS | FnType.MODULE
312312
self.receiver = None
313313
if fntype in FnType.CLASS | FnType.INSTANCE:
@@ -351,7 +351,15 @@ class MessageEvent(Event): # pylint: disable=too-few-public-methods
351351
def __init__(self, message_parameters):
352352
super().__init__("call")
353353
self.message = []
354-
for name, value in message_parameters.items():
354+
self.message_parameters = message_parameters
355+
356+
@property
357+
def message_parameters(self):
358+
return self.message
359+
360+
@message_parameters.setter
361+
def message_parameters(self, params):
362+
for name, value in params.items():
355363
message_object = describe_value(name, value)
356364
self.message.append(message_object)
357365

@@ -386,6 +394,7 @@ def __init__(self, request_method, url, message_parameters, headers=None):
386394

387395

388396
# pylint: disable=too-few-public-methods
397+
_NORMALIZED_PATH_INFO_ATTR = "normalized_path_info"
389398
class HttpServerRequestEvent(MessageEvent):
390399
"""A call AppMap event representing an HTTP server request."""
391400

@@ -406,7 +415,7 @@ def __init__(
406415
"request_method": request_method,
407416
"protocol": protocol,
408417
"path_info": path_info,
409-
"normalized_path_info": normalized_path_info,
418+
_NORMALIZED_PATH_INFO_ATTR: normalized_path_info,
410419
}
411420

412421
if headers is not None:
@@ -420,6 +429,14 @@ def __init__(
420429

421430
self.http_server_request = compact_dict(request)
422431

432+
@property
433+
def normalized_path_info(self):
434+
return self.http_server_request.get(_NORMALIZED_PATH_INFO_ATTR, None)
435+
436+
@normalized_path_info.setter
437+
def normalized_path_info(self, npi):
438+
self.http_server_request[_NORMALIZED_PATH_INFO_ATTR] = npi
439+
423440

424441
class ReturnEvent(Event):
425442
__slots__ = ["parent_id", "elapsed"]

_appmap/fastapi.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""
2+
This file contains a FastAPI app that is mounted on /_appmap to expose the remote-recording endpoint
3+
in a user's app.
4+
5+
It should only be imported if other code has already verified that FastAPI is available.
6+
"""
7+
8+
from fastapi import FastAPI, Response
9+
10+
from . import remote_recording
11+
12+
app = FastAPI()
13+
14+
15+
def _rr_response(fn):
16+
body, rrstatus = fn()
17+
return Response(content=body, status_code=rrstatus, media_type="application/json")
18+
19+
20+
@app.get("/record")
21+
def status():
22+
return _rr_response(remote_recording.status)
23+
24+
25+
@app.post("/record")
26+
def start():
27+
return _rr_response(remote_recording.start)
28+
29+
30+
@app.delete("/record")
31+
def stop():
32+
return _rr_response(remote_recording.stop)

_appmap/importer.py

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,56 +10,49 @@
1010
from _appmap import wrapt
1111

1212
from .env import Env
13-
from .utils import FnType
13+
from .utils import FnType, Scope
1414

1515
logger = Env.current.getLogger(__name__)
1616

1717

18-
Filterable = namedtuple("Filterable", "fqname obj")
18+
Filterable = namedtuple("Filterable", "scope fqname obj")
1919

2020

2121
class FilterableMod(Filterable):
2222
__slots__ = ()
2323

2424
def __new__(cls, mod):
2525
fqname = mod.__name__
26-
return super(FilterableMod, cls).__new__(cls, fqname, mod)
27-
28-
def classify_fn(self, _):
29-
return FnType.MODULE
26+
return super(FilterableMod, cls).__new__(cls, Scope.MODULE, fqname, mod)
3027

3128

3229
class FilterableCls(Filterable):
3330
__slots__ = ()
3431

3532
def __new__(cls, clazz):
3633
fqname = "%s.%s" % (clazz.__module__, clazz.__qualname__)
37-
return super(FilterableCls, cls).__new__(cls, fqname, clazz)
38-
39-
def classify_fn(self, static_fn):
40-
return FnType.classify(static_fn)
34+
return super(FilterableCls, cls).__new__(cls, Scope.CLASS, fqname, clazz)
4135

4236

4337
class FilterableFn(
4438
namedtuple(
4539
"FilterableFn",
46-
Filterable._fields
47-
+ (
48-
"scope",
49-
"static_fn",
50-
),
40+
Filterable._fields + ("static_fn",),
5141
)
5242
):
5343
__slots__ = ()
5444

5545
def __new__(cls, scope, fn, static_fn):
5646
fqname = "%s.%s" % (scope.fqname, fn.__name__)
57-
self = super(FilterableFn, cls).__new__(cls, fqname, fn, scope, static_fn)
47+
self = super(FilterableFn, cls).__new__(cls, scope.scope, fqname, fn, static_fn)
5848
return self
5949

6050
@property
6151
def fntype(self):
62-
return self.scope.classify_fn(self.static_fn)
52+
if self.scope == Scope.MODULE:
53+
return FnType.MODULE
54+
55+
return FnType.classify(self.static_fn)
6356

6457

6558
class Filter(ABC): # pylint: disable=too-few-public-methods
@@ -161,6 +154,17 @@ def initialize(cls):
161154
def use_filter(cls, filter_class):
162155
cls.filter_stack.append(filter_class)
163156

157+
@classmethod
158+
def instrument_function(cls, fn_name, filterableFn: FilterableFn, selected_functions=None):
159+
# Only instrument the function if it was specifically called out for the package
160+
# (e.g. because it should be labeled), or it's included by the filters
161+
matched = cls.filter_chain.filter(filterableFn)
162+
selected = selected_functions and fn_name in selected_functions
163+
if selected or matched:
164+
return cls.filter_chain.wrap(filterableFn)
165+
166+
return filterableFn.obj
167+
164168
@classmethod
165169
def do_import(cls, *args, **kwargs):
166170
mod = args[0]
@@ -177,15 +181,10 @@ def instrument_functions(filterable, selected_functions=None):
177181
logger.trace(" functions %s", functions)
178182

179183
for fn_name, static_fn, fn in functions:
180-
# Only instrument the function if it was specifically called out for the package
181-
# (e.g. because it should be labeled), or it's included by the filters
182184
filterableFn = FilterableFn(filterable, fn, static_fn)
183-
matched = cls.filter_chain.filter(filterableFn)
184-
selected = selected_functions and fn_name in selected_functions
185-
if selected or matched:
186-
new_fn = cls.filter_chain.wrap(filterableFn)
187-
if fn != new_fn:
188-
wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn)
185+
new_fn = cls.instrument_function(fn_name, filterableFn, selected_functions)
186+
if new_fn != fn:
187+
wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn)
189188

190189
# Import Config here, to avoid circular top-level imports.
191190
from .configuration import Config # pylint: disable=import-outside-toplevel

_appmap/metadata.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Shared metadata gathering"""
22

33
import platform
4-
import re
54
from functools import lru_cache
65

76
from . import utils

_appmap/remote_recording.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
""" remote_recording is a Flask app that can be mounted to expose the remote-recording endpoint. """
1+
""" remote_recording contains the functions neccessary to implement a remote-recording endpoint. """
2+
23
import json
34
from threading import Lock
45

_appmap/test/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import pytest
2+
3+
# Make sure assertions in web_framework get rewritten (e.g. to show
4+
# diffs in generated appmaps)
5+
pytest.register_assert_rewrite("_appmap.test.web_framework")

_appmap/test/appmap_test_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import platform
33
import re
4+
from importlib.metadata import version as dist_version
45
from operator import itemgetter
56

67
import pytest
@@ -60,7 +61,7 @@ def normalize_metadata(metadata):
6061
for f in frameworks:
6162
if f["name"] == "pytest":
6263
v = f.pop("version")
63-
assert v == pytest.__version__
64+
assert v == dist_version("pytest")
6465

6566
def normalize_appmap(self, generated_appmap):
6667
"""

_appmap/test/bin/server_runner

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env bash
2+
3+
set -x
4+
5+
cd "$1"; shift
6+
7+
set -a
8+
PYTHONUNBUFFERED=1
9+
APPMAP_OUTPUT_DIR=/tmp
10+
PYTHONPATH=./init
11+
12+
exec $@

0 commit comments

Comments
 (0)