Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion app/handlers/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import inspect
import sys
import time
import uuid
from contextlib import contextmanager
Expand All @@ -25,7 +26,14 @@
from ..env import load_env
from ..flow import Flow
from ..json_util import to_json
from ..models import DBSession, User, VerifiedSession, bulk_verify, session_context_id
from ..models import (
APICall,
DBSession,
User,
VerifiedSession,
bulk_verify,
session_context_id,
)

env, cfg = load_env()
log = make_log("basehandler")
Expand All @@ -40,6 +48,18 @@
# https://python-social-auth.readthedocs.io/en/latest/backends/implementation.html#auth-apis


def sizeof(obj):
"""Estimates total memory usage of (possibly nested) `obj` by recursively calling sys.getsizeof() for list/tuple/dict/set containers
and adding up the results. Does NOT handle circular object references!
"""
size = sys.getsizeof(obj)
if isinstance(obj, dict):
return size + sum(map(sizeof, obj.keys())) + sum(map(sizeof, obj.values()))
if isinstance(obj, (list, tuple, set, frozenset)):
return size + sum(map(sizeof, obj))
return size


class NoValue:
pass

Expand Down Expand Up @@ -305,6 +325,30 @@ def error(self, message, data={}, status=400, extra={}):
self.set_status(status)
self.write({"status": "error", "message": message, "data": data, **extra})

if self.current_user is None:
return
with DBSession() as session:
session.merge(self.current_user)
if hasattr(self.current_user, "created_by_id"):
user_id = int(self.current_user.created_by_id)
else:
user_id = int(self.current_user.id)

uri_split = self.request.uri.replace("api/", "").split("?")
uri = uri_split[0]
params = "/".join(uri_split[1:])

api_call = APICall(
user_id=user_id,
method=self.request.method,
uri=uri,
params=params,
size=sizeof(data),
success=False,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if you stick something like self.start_time = time.time() up in the prepare method, you could also get the runtime of the query. That should work unless there's async stuff happening, in which case who knows what will happen... but it is worth trying out and comparing to timing data you get from the side calling the API.

)
session.add(api_call)
session.commit()

def action(self, action, payload={}):
"""Push an action to the frontend via WebSocket connection.

Expand Down Expand Up @@ -354,6 +398,30 @@ def success(self, data={}, action=None, payload={}, status=200, extra={}):
self.set_status(status)
self.write(to_json({"status": "success", "data": data, **extra}))

if self.current_user is None:
return
with DBSession() as session:
session.merge(self.current_user)
if hasattr(self.current_user, "created_by_id"):
user_id = int(self.current_user.created_by_id)
else:
user_id = int(self.current_user.id)

uri_split = self.request.uri.replace("api/", "").split("?")
uri = uri_split[0]
params = "/".join(uri_split[1:])

api_call = APICall(
user_id=user_id,
method=self.request.method,
uri=uri,
params=params,
size=sizeof(data),
success=True,
)
session.add(api_call)
session.commit()

def write_error(self, status_code, exc_info=None):
if exc_info is not None:
err_cls, err, traceback = exc_info
Expand Down
52 changes: 52 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1889,6 +1889,12 @@ class User(Base):
doc="ACLs granted to user, separate from role-level ACLs",
lazy="selectin",
)
apicalls = relationship(
"APICall",
passive_deletes=True,
doc="API Calls by the user",
lazy="selectin",
)
expiration_date = sa.Column(
sa.DateTime,
nullable=True,
Expand Down Expand Up @@ -2035,3 +2041,49 @@ class CronJobRun(Base):
sa.String,
doc="Cron job's subprocess output, or exception string.",
)


class APICall(Base):
"""An API call by a User."""

create = read = update = delete = accessible_by_user

user_id = sa.Column(
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
doc="The ID of the User that made the API call.",
)
user = relationship(
"User",
back_populates="apicalls",
lazy="selectin",
doc="The User that made the API call.",
)

uri = sa.Column(
sa.String,
nullable=False,
doc="The endpoint of the API call.",
)

params = sa.Column(
sa.String,
nullable=True,
doc="The parameters of the API call.",
)

method = sa.Column(
sa.String,
nullable=False,
doc="The HTTP method of the API call.",
)

size = sa.Column(
sa.Float,
nullable=False,
doc="The size of the query in bytes.",
)

success = sa.Column(
sa.Boolean, nullable=False, doc="Was the call successful or not?"
)