diff --git a/datasette/app.py b/datasette/app.py index 482cebb4ca..112fb85ed3 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -34,6 +34,7 @@ from jinja2.environment import Template from jinja2.exceptions import TemplateNotFound +from .events import Event from .views import Context from .views.base import ureg from .views.database import database_download, DatabaseView, TableCreateView @@ -436,6 +437,13 @@ def __init__( self._root_token = secrets.token_hex(32) self.client = DatasetteClient(self) + # Register all event classes + event_classes = [] + for hook in pm.hook.register_events(datasette=self): + if hook: + event_classes.extend(hook) + self.event_classes = tuple(event_classes) + def get_jinja_environment(self, request: Request = None) -> Environment: environment = self._jinja_env if request: @@ -873,6 +881,17 @@ async def actors_from_ids( result = await await_me_maybe(result) return result + async def track_event(self, event: Event): + assert isinstance(event, self.event_classes), "Invalid event type: {}".format( + type(event) + ) + properties = dataclasses.asdict(event) + actor = properties.pop("actor") + for hook in pm.hook.track_event( + datasette=self, name=event.name, actor=actor, properties=properties + ): + await await_me_maybe(hook) + async def permission_allowed( self, actor, action, resource=None, default=DEFAULT_NOT_SET ): diff --git a/datasette/events.py b/datasette/events.py new file mode 100644 index 0000000000..fbba064b58 --- /dev/null +++ b/datasette/events.py @@ -0,0 +1,30 @@ +from abc import ABC, abstractproperty +from dataclasses import dataclass +from datasette.hookspecs import hookimpl + + +@dataclass +class Event(ABC): + @abstractproperty + def name(self): + pass + + actor: dict + + +@dataclass +class LogoutEvent(Event): + name = "logout" + + +@dataclass +class CreateTableEvent(Event): + name = "create-table" + database: str + table: str + schema: str + + +@hookimpl +def register_events(): + return [LogoutEvent, CreateTableEvent] diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 2f4c602774..c297e35264 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -160,6 +160,16 @@ def handle_exception(datasette, request, exception): """Handle an uncaught exception. Can return a Response or None.""" +@hookspec +def track_event(datasette, name, actor, properties): + """Respond to a named event tracked by Datasette""" + + +@hookspec +def register_events(datasette): + """Return a list of Event subclasses to use with track_event()""" + + @hookspec def top_homepage(datasette, request): """HTML to include at the top of the homepage""" diff --git a/datasette/plugins.py b/datasette/plugins.py index 1ed3747f85..f7a1905f4e 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -27,6 +27,7 @@ "datasette.default_menu_links", "datasette.handle_exception", "datasette.forbidden", + "datasette.events", ) pm = pluggy.PluginManager("datasette") diff --git a/datasette/views/database.py b/datasette/views/database.py index eac01ab6c6..6d17b16c1b 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -10,6 +10,7 @@ import sqlite_utils import textwrap +from datasette.events import CreateTableEvent from datasette.database import QueryInterrupted from datasette.utils import ( add_cors_headers, @@ -969,6 +970,11 @@ def create_table(conn): } if rows: details["row_count"] = len(rows) + await self.ds.track_event( + CreateTableEvent( + request.actor, database=db.name, table=table_name, schema=schema + ) + ) return Response.json(details, status=201) diff --git a/datasette/views/special.py b/datasette/views/special.py index 849750bff5..9e1929269a 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -1,4 +1,5 @@ import json +from datasette.events import LogoutEvent from datasette.utils.asgi import Response, Forbidden from datasette.utils import ( actor_matches_allow, @@ -105,6 +106,7 @@ async def post(self, request): response = Response.redirect(self.ds.urls.instance()) response.set_cookie("ds_actor", "", expires=0, max_age=0) self.ds.add_message(request, "You are now logged out", self.ds.WARNING) + await self.ds.track_event(LogoutEvent(request.actor)) return response