Skip to content

Commit 6dc7d53

Browse files
authored
Emit events from the Contents Service (#954)
* emit events from the contents manager * remove unused imports * return default event_logger in contents manager * remove unused actions
1 parent cc7e6c4 commit 6dc7d53

File tree

6 files changed

+124
-2
lines changed

6 files changed

+124
-2
lines changed

jupyter_server/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""The Jupyter Server"""
22
import os
3+
import pathlib
34
import subprocess
45
import sys
56

@@ -10,6 +11,8 @@
1011
]
1112

1213
DEFAULT_JUPYTER_SERVER_PORT = 8888
14+
JUPYTER_SERVER_EVENTS_URI = "https://events.jupyter.org/jupyter_server"
15+
DEFAULT_EVENTS_SCHEMA_PATH = pathlib.Path(__file__).parent / "event_schemas"
1316

1417
del os
1518

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"$id": https://events.jupyter.org/jupyter_server/contents_service/v1
2+
version: 1
3+
title: Contents Manager activities
4+
personal-data: true
5+
description: |
6+
Record actions on files via the ContentsManager.
7+
8+
The notebook ContentsManager REST API is used by all frontends to retreive,
9+
save, list, delete and perform other actions on notebooks, directories,
10+
and other files through the UI. This is pluggable - the default acts on
11+
the file system, but can be replaced with a different ContentsManager
12+
implementation - to work on S3, Postgres, other object stores, etc.
13+
The events get recorded regardless of the ContentsManager implementation
14+
being used.
15+
16+
Limitations:
17+
18+
1. This does not record all filesystem access, just the ones that happen
19+
explicitly via the notebook server's REST API. Users can (and often do)
20+
trivially access the filesystem in many other ways (such as `open()` calls
21+
in their code), so this is usually never a complete record.
22+
2. As with all events recorded by the notebook server, users most likely
23+
have the ability to modify the code of the notebook server. Unless other
24+
security measures are in place, these events should be treated as user
25+
controlled and not used in high security areas.
26+
3. Events are only recorded when an action succeeds.
27+
type: object
28+
required:
29+
- action
30+
- path
31+
properties:
32+
action:
33+
enum:
34+
- get
35+
- create
36+
- save
37+
- upload
38+
- rename
39+
- copy
40+
- delete
41+
description: |
42+
Action performed by the ContentsManager API.
43+
44+
This is a required field.
45+
46+
Possible values:
47+
48+
1. get
49+
Get contents of a particular file, or list contents of a directory.
50+
51+
2. save
52+
Save a file at path with contents from the client
53+
54+
3. rename
55+
Rename a file or directory from value in source_path to
56+
value in path.
57+
58+
4. copy
59+
Copy a file or directory from value in source_path to
60+
value in path.
61+
62+
5. delete
63+
Delete a file or empty directory at given path
64+
path:
65+
type: string
66+
description: |
67+
Logical path on which the operation was performed.
68+
69+
This is a required field.
70+
source_path:
71+
type: string
72+
description: |
73+
Source path of an operation when action is 'copy' or 'rename'

jupyter_server/serverapp.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,11 @@
8383
from traitlets.config.application import boolean_flag, catch_config_error
8484

8585
from jupyter_server import (
86+
DEFAULT_EVENTS_SCHEMA_PATH,
8687
DEFAULT_JUPYTER_SERVER_PORT,
8788
DEFAULT_STATIC_FILES_PATH,
8889
DEFAULT_TEMPLATE_PATH_LIST,
90+
JUPYTER_SERVER_EVENTS_URI,
8991
__version__,
9092
)
9193
from jupyter_server._sysinfo import get_sys_info
@@ -1951,6 +1953,19 @@ def init_logging(self):
19511953
def init_event_logger(self):
19521954
"""Initialize the Event Bus."""
19531955
self.event_logger = EventLogger(parent=self)
1956+
# Load the core Jupyter Server event schemas
1957+
# All event schemas must start with Jupyter Server's
1958+
# events URI, `JUPYTER_SERVER_EVENTS_URI`.
1959+
schema_ids = [
1960+
"https://events.jupyter.org/jupyter_server/contents_service/v1",
1961+
]
1962+
for schema_id in schema_ids:
1963+
# Get the schema path from the schema ID.
1964+
rel_schema_path = schema_id.lstrip(JUPYTER_SERVER_EVENTS_URI) + ".yaml"
1965+
schema_path = DEFAULT_EVENTS_SCHEMA_PATH / rel_schema_path
1966+
# Use this pathlib object to register the schema
1967+
# breakpoint()
1968+
self.event_logger.register_event_schema(schema_path)
19541969

19551970
def init_webapp(self):
19561971
"""initialize tornado webapp"""

jupyter_server/services/contents/filemanager.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ def get(self, path, content=True, type=None, format=None):
395395
if type == "directory":
396396
raise web.HTTPError(400, "%s is not a directory" % path, reason="bad type")
397397
model = self._file_model(path, content=content, format=format)
398+
self.emit(data={"action": "get", "path": path})
398399
return model
399400

400401
def _save_directory(self, os_path, model, path=""):
@@ -459,7 +460,7 @@ def save(self, model, path=""):
459460
model["message"] = validation_message
460461

461462
self.run_post_save_hooks(model=model, os_path=os_path)
462-
463+
self.emit(data={"action": "save", "path": path})
463464
return model
464465

465466
def delete_file(self, path):
@@ -735,6 +736,7 @@ async def get(self, path, content=True, type=None, format=None):
735736
if type == "directory":
736737
raise web.HTTPError(400, "%s is not a directory" % path, reason="bad type")
737738
model = await self._file_model(path, content=content, format=format)
739+
self.emit(data={"action": "get", "path": path})
738740
return model
739741

740742
async def _save_directory(self, os_path, model, path=""):
@@ -795,7 +797,7 @@ async def save(self, model, path=""):
795797
model["message"] = validation_message
796798

797799
self.run_post_save_hooks(model=model, os_path=os_path)
798-
800+
self.emit(data={"action": "save", "path": path})
799801
return model
800802

801803
async def delete_file(self, path):

jupyter_server/services/contents/largefilemanager.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def save(self, model, path=""):
5454
# Last chunk
5555
if chunk == -1:
5656
self.run_post_save_hooks(model=model, os_path=os_path)
57+
self.emit(data={"action": "save", "path": path})
5758
return model
5859
else:
5960
return super().save(model, path)
@@ -125,6 +126,8 @@ async def save(self, model, path=""):
125126
# Last chunk
126127
if chunk == -1:
127128
self.run_post_save_hooks(model=model, os_path=os_path)
129+
130+
self.emit(data={"action": "save", "path": path})
128131
return model
129132
else:
130133
return await super().save(model, path)

jupyter_server/services/contents/manager.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import warnings
88
from fnmatch import fnmatch
99

10+
from jupyter_events import EventLogger
1011
from nbformat import ValidationError, sign
1112
from nbformat import validate as validate_nb
1213
from nbformat.v4 import new_notebook
@@ -25,6 +26,7 @@
2526
)
2627
from traitlets.config.configurable import LoggingConfigurable
2728

29+
from jupyter_server import DEFAULT_EVENTS_SCHEMA_PATH, JUPYTER_SERVER_EVENTS_URI
2830
from jupyter_server.transutils import _i18n
2931
from jupyter_server.utils import ensure_async, import_item
3032

@@ -53,6 +55,24 @@ class ContentsManager(LoggingConfigurable):
5355
5456
"""
5557

58+
event_schema_id = JUPYTER_SERVER_EVENTS_URI + "/contents_service/v1"
59+
event_logger = Instance(EventLogger).tag(config=True)
60+
61+
@default("event_logger")
62+
def _default_event_logger(self):
63+
if self.parent and hasattr(self.parent, "event_logger"):
64+
return self.parent.event_logger
65+
else:
66+
# If parent does not have an event logger, create one.
67+
logger = EventLogger()
68+
schema_path = DEFAULT_EVENTS_SCHEMA_PATH / "contents_service" / "v1.yaml"
69+
logger.register_event_schema(schema_path)
70+
return logger
71+
72+
def emit(self, data):
73+
"""Emit event using the core event schema from Jupyter Server's Contents Manager."""
74+
self.event_logger.emit(schema_id=self.event_schema_id, data=data)
75+
5676
root_dir = Unicode("/", config=True)
5777

5878
allow_hidden = Bool(False, config=True, help="Allow access to hidden files")
@@ -416,11 +436,13 @@ def delete(self, path):
416436
raise HTTPError(400, "Can't delete root")
417437
self.delete_file(path)
418438
self.checkpoints.delete_all_checkpoints(path)
439+
self.emit(data={"action": "delete", "path": path})
419440

420441
def rename(self, old_path, new_path):
421442
"""Rename a file and any checkpoints associated with that file."""
422443
self.rename_file(old_path, new_path)
423444
self.checkpoints.rename_all_checkpoints(old_path, new_path)
445+
self.emit(data={"action": "rename", "path": new_path, "source_path": old_path})
424446

425447
def update(self, model, path):
426448
"""Update the file's path
@@ -616,6 +638,7 @@ def copy(self, from_path, to_path=None):
616638
raise HTTPError(404, "No such directory: %s" % to_path)
617639

618640
model = self.save(model, to_path)
641+
self.emit(data={"action": "copy", "path": to_path, "source_path": from_path})
619642
return model
620643

621644
def log_info(self):
@@ -819,11 +842,13 @@ async def delete(self, path):
819842

820843
await self.delete_file(path)
821844
await self.checkpoints.delete_all_checkpoints(path)
845+
self.emit(data={"action": "delete", "path": path})
822846

823847
async def rename(self, old_path, new_path):
824848
"""Rename a file and any checkpoints associated with that file."""
825849
await self.rename_file(old_path, new_path)
826850
await self.checkpoints.rename_all_checkpoints(old_path, new_path)
851+
self.emit(data={"action": "rename", "path": new_path, "source_path": old_path})
827852

828853
async def update(self, model, path):
829854
"""Update the file's path
@@ -985,6 +1010,7 @@ async def copy(self, from_path, to_path=None):
9851010
raise HTTPError(404, "No such directory: %s" % to_path)
9861011

9871012
model = await self.save(model, to_path)
1013+
self.emit(data={"action": "copy", "path": to_path, "source_path": from_path})
9881014
return model
9891015

9901016
async def trust_notebook(self, path):

0 commit comments

Comments
 (0)