Skip to content

Commit

Permalink
track_event() mechanism for analytics and plugins
Browse files Browse the repository at this point in the history
* Closes #2240
* Documentation for event plugin hooks, refs #2240
* Include example track_event plugin in docs, refs #2240
* Tests for track_event() and register_events() hooks, refs #2240
* Initial documentation for core events, refs #2240
* Internals documentation for datasette.track_event()
  • Loading branch information
simonw authored Jan 31, 2024
1 parent 890615b commit bcc4f6b
Show file tree
Hide file tree
Showing 22 changed files with 614 additions and 10 deletions.
1 change: 1 addition & 0 deletions datasette/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datasette.permissions import Permission # noqa
from datasette.version import __version_info__, __version__ # noqa
from datasette.events import Event # noqa
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
from datasette.utils import actor_matches_allow # noqa
from datasette.views import Context # noqa
Expand Down
16 changes: 16 additions & 0 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -505,6 +506,14 @@ async def invoke_startup(self):
# This must be called for Datasette to be in a usable state
if self._startup_invoked:
return
# Register event classes
event_classes = []
for hook in pm.hook.register_events(datasette=self):
extra_classes = await await_me_maybe(hook)
if extra_classes:
event_classes.extend(extra_classes)
self.event_classes = tuple(event_classes)

# Register permissions, but watch out for duplicate name/abbr
names = {}
abbrs = {}
Expand Down Expand Up @@ -873,6 +882,13 @@ 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)
)
for hook in pm.hook.track_event(datasette=self, event=event):
await await_me_maybe(hook)

async def permission_allowed(
self, actor, action, resource=None, default=DEFAULT_NOT_SET
):
Expand Down
211 changes: 211 additions & 0 deletions datasette/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
from abc import ABC, abstractproperty
from dataclasses import asdict, dataclass, field
from datasette.hookspecs import hookimpl
from datetime import datetime, timezone
from typing import Optional


@dataclass
class Event(ABC):
@abstractproperty
def name(self):
pass

created: datetime = field(
init=False, default_factory=lambda: datetime.now(timezone.utc)
)
actor: Optional[dict]

def properties(self):
properties = asdict(self)
properties.pop("actor", None)
properties.pop("created", None)
return properties


@dataclass
class LoginEvent(Event):
"""
Event name: ``login``
A user (represented by ``event.actor``) has logged in.
"""

name = "login"


@dataclass
class LogoutEvent(Event):
"""
Event name: ``logout``
A user (represented by ``event.actor``) has logged out.
"""

name = "logout"


@dataclass
class CreateTokenEvent(Event):
"""
Event name: ``create-token``
A user created an API token.
:ivar expires_after: Number of seconds after which this token will expire.
:type expires_after: int or None
:ivar restrict_all: Restricted permissions for this token.
:type restrict_all: list
:ivar restrict_database: Restricted database permissions for this token.
:type restrict_database: dict
:ivar restrict_resource: Restricted resource permissions for this token.
:type restrict_resource: dict
"""

name = "create-token"
expires_after: Optional[int]
restrict_all: list
restrict_database: dict
restrict_resource: dict


@dataclass
class CreateTableEvent(Event):
"""
Event name: ``create-table``
A new table has been created in the database.
:ivar database: The name of the database where the table was created.
:type database: str
:ivar table: The name of the table that was created
:type table: str
:ivar schema: The SQL schema definition for the new table.
:type schema: str
"""

name = "create-table"
database: str
table: str
schema: str


@dataclass
class DropTableEvent(Event):
"""
Event name: ``drop-table``
A table has been dropped from the database.
:ivar database: The name of the database where the table was dropped.
:type database: str
:ivar table: The name of the table that was dropped
:type table: str
"""

name = "drop-table"
database: str
table: str


@dataclass
class InsertRowsEvent(Event):
"""
Event name: ``insert-rows``
Rows were inserted into a table.
:ivar database: The name of the database where the rows were inserted.
:type database: str
:ivar table: The name of the table where the rows were inserted.
:type table: str
:ivar num_rows: The number of rows that were requested to be inserted.
:type num_rows: int
:ivar ignore: Was ignore set?
:type ignore: bool
:ivar replace: Was replace set?
:type replace: bool
"""

name = "insert-rows"
database: str
table: str
num_rows: int
ignore: bool
replace: bool


@dataclass
class UpsertRowsEvent(Event):
"""
Event name: ``upsert-rows``
Rows were upserted into a table.
:ivar database: The name of the database where the rows were inserted.
:type database: str
:ivar table: The name of the table where the rows were inserted.
:type table: str
:ivar num_rows: The number of rows that were requested to be inserted.
:type num_rows: int
"""

name = "upsert-rows"
database: str
table: str
num_rows: int


@dataclass
class UpdateRowEvent(Event):
"""
Event name: ``update-row``
A row was updated in a table.
:ivar database: The name of the database where the row was updated.
:type database: str
:ivar table: The name of the table where the row was updated.
:type table: str
:ivar pks: The primary key values of the updated row.
"""

name = "update-row"
database: str
table: str
pks: list


@dataclass
class DeleteRowEvent(Event):
"""
Event name: ``delete-row``
A row was deleted from a table.
:ivar database: The name of the database where the row was deleted.
:type database: str
:ivar table: The name of the table where the row was deleted.
:type table: str
:ivar pks: The primary key values of the deleted row.
"""

name = "delete-row"
database: str
table: str
pks: list


@hookimpl
def register_events():
return [
LoginEvent,
LogoutEvent,
CreateTableEvent,
CreateTokenEvent,
DropTableEvent,
InsertRowsEvent,
UpsertRowsEvent,
UpdateRowEvent,
DeleteRowEvent,
]
10 changes: 10 additions & 0 deletions datasette/hookspecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, event):
"""Respond to an 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"""
Expand Down
1 change: 1 addition & 0 deletions datasette/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"datasette.default_menu_links",
"datasette.handle_exception",
"datasette.forbidden",
"datasette.events",
)

pm = pluggy.PluginManager("datasette")
Expand Down
6 changes: 6 additions & 0 deletions datasette/views/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)


Expand Down
20 changes: 20 additions & 0 deletions datasette/views/row.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datasette.utils.asgi import NotFound, Forbidden, Response
from datasette.database import QueryInterrupted
from datasette.events import UpdateRowEvent, DeleteRowEvent
from .base import DataView, BaseView, _error
from datasette.utils import (
make_slot_function,
Expand Down Expand Up @@ -200,6 +201,15 @@ def delete_row(conn):
except Exception as e:
return _error([str(e)], 500)

await self.ds.track_event(
DeleteRowEvent(
actor=request.actor,
database=resolved.db.name,
table=resolved.table,
pks=resolved.pk_values,
)
)

return Response.json({"ok": True}, status=200)


Expand Down Expand Up @@ -246,4 +256,14 @@ def update_row(conn):
)
rows = list(results.rows)
result["row"] = dict(rows[0])

await self.ds.track_event(
UpdateRowEvent(
actor=request.actor,
database=resolved.db.name,
table=resolved.table,
pks=resolved.pk_values,
)
)

return Response.json(result, status=200)
17 changes: 14 additions & 3 deletions datasette/views/special.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
from datasette.utils.asgi import Response, Forbidden
from datasette.utils import (
actor_matches_allow,
Expand Down Expand Up @@ -80,9 +81,9 @@ async def get(self, request):
if secrets.compare_digest(token, self.ds._root_token):
self.ds._root_token = None
response = Response.redirect(self.ds.urls.instance())
response.set_cookie(
"ds_actor", self.ds.sign({"a": {"id": "root"}}, "actor")
)
root_actor = {"id": "root"}
response.set_cookie("ds_actor", self.ds.sign({"a": root_actor}, "actor"))
await self.ds.track_event(LoginEvent(actor=root_actor))
return response
else:
raise Forbidden("Invalid token")
Expand All @@ -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(actor=request.actor))
return response


Expand Down Expand Up @@ -349,6 +351,15 @@ async def post(self, request):
restrict_resource=restrict_resource,
)
token_bits = self.ds.unsign(token[len("dstok_") :], namespace="token")
await self.ds.track_event(
CreateTokenEvent(
actor=request.actor,
expires_after=expires_after,
restrict_all=restrict_all,
restrict_database=restrict_database,
restrict_resource=restrict_resource,
)
)
context = await self.shared(request)
context.update({"errors": errors, "token": token, "token_bits": token_bits})
return await self.render(["create_token.html"], request, context)
Expand Down
Loading

0 comments on commit bcc4f6b

Please sign in to comment.