├── .yarnrc.yml ├── style ├── index.js ├── index.css └── base.css ├── setup.py ├── .prettierignore ├── configuration_examples ├── compose.yaml ├── .dockerignore ├── file_exporter │ └── jupyter_jupyterlab_pioneer_config.py ├── console_exporter │ └── jupyter_jupyterlab_pioneer_config.py ├── custom_exporter │ └── jupyter_jupyterlab_pioneer_config.py ├── Dockerfile ├── remote_exporter │ └── jupyter_jupyterlab_pioneer_config.py └── all_exporters │ └── jupyter_jupyterlab_pioneer_config.py ├── docs ├── requirements.txt ├── quick_start.rst ├── Makefile ├── jupyterlab_pioneer.rst ├── make.bat ├── architecture.rst ├── installation.rst ├── index.rst ├── event_library.rst ├── conf.py ├── contributing.rst ├── configuration.rst └── custom_event.rst ├── jupyter-config └── server-config │ └── jupyterlab_pioneer.json ├── jupyterlab_pioneer ├── _version.py ├── __init__.py ├── application.py ├── handlers.py └── default_exporters.py ├── install.json ├── .copier-answers.yml ├── tsconfig.json ├── .readthedocs.yaml ├── src ├── handler.ts ├── types.tsx ├── utils.tsx ├── index.ts └── producer.ts ├── LICENSE ├── .github └── workflows │ ├── prep-release.yml │ ├── publish-release.yml │ ├── update-integration-tests.yml │ └── build.yml ├── .all-contributorsrc ├── .gitignore ├── pyproject.toml ├── CHANGELOG.md ├── RELEASE.md ├── environment.yml ├── package.json └── README.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | __import__("setuptools").setup() 2 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import url('base.css'); 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | !/package.json 6 | jupyterlab_pioneer 7 | -------------------------------------------------------------------------------- /configuration_examples/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | build: 4 | context: . 5 | ports: 6 | - 8888:8888 7 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==7.2.6 2 | jupyterlab_pioneer 3 | sphinxcontrib.napoleon==0.7 4 | sphinx_rtd_theme==1.3.0 5 | sphinxcontrib.mermaid 6 | -------------------------------------------------------------------------------- /jupyter-config/server-config/jupyterlab_pioneer.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyterlab_pioneer": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- 1 | /* 2 | See the JupyterLab Developer Guide for useful CSS Patterns: 3 | 4 | https://jupyterlab.readthedocs.io/en/stable/developer/css.html 5 | */ 6 | -------------------------------------------------------------------------------- /jupyterlab_pioneer/_version.py: -------------------------------------------------------------------------------- 1 | # This file is auto-generated by Hatchling. As such, do not: 2 | # - modify 3 | # - track in version control e.g. be sure to add to .gitignore 4 | __version__ = VERSION = '1.7.1' 5 | -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupyterlab_pioneer", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyterlab_pioneer" 5 | } 6 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v4.2.0 3 | _src_path: https://github.com/jupyterlab/extension-template 4 | author_email: etc-jupyterlab-telemetry@umich.edu 5 | author_name: Educational Technology Collective 6 | has_binder: false 7 | has_settings: false 8 | kind: server 9 | labextension_name: jupyterlab-pioneer 10 | project_short_description: A JupyterLab extension. 11 | python_name: jupyterlab_pioneer 12 | repository: https://github.com/educational-technology-collective/jupyterlab-pioneer 13 | test: false 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": false, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "rootDir": "src", 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "target": "ES2018" 21 | }, 22 | "include": ["src/*"] 23 | } 24 | -------------------------------------------------------------------------------- /docs/quick_start.rst: -------------------------------------------------------------------------------- 1 | Quick start 2 | =========== 3 | 4 | Set up the extension with docker compose 5 | ---------------------------------------- 6 | :: 7 | 8 | # git clone https://github.com/educational-technology-collective/jupyterlab-pioneer 9 | # cd configuration_examples 10 | docker compose -p jupyterlab_pioneer up --build 11 | 12 | A JupyterLab application with the extension installed and configured will run on `localhost:8888`. 13 | 14 | (To play with different exporter configurations, edit Dockerfile_ and run ``docker compose`` again) 15 | 16 | .. _Dockerfile: https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/configuration_examples/Dockerfile 17 | -------------------------------------------------------------------------------- /jupyterlab_pioneer/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._version import __version__ 3 | except ImportError: 4 | import warnings 5 | warnings.warn("Importing 'jupyterlab_pioneer' outside a proper installation.") 6 | __version__ = "dev" 7 | 8 | from .application import JupyterLabPioneerApp 9 | 10 | def _jupyter_labextension_paths(): 11 | return [{ 12 | "src": "labextension", 13 | "dest": "jupyterlab-pioneer" 14 | }] 15 | 16 | def _jupyter_server_extension_points(): 17 | return [{ 18 | "module": "jupyterlab_pioneer", 19 | "app": JupyterLabPioneerApp 20 | }] 21 | 22 | load_jupyter_server_extension = JupyterLabPioneerApp.load_classic_server_extension 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/jupyterlab_pioneer.rst: -------------------------------------------------------------------------------- 1 | jupyterlab\_pioneer package 2 | =========================== 3 | 4 | jupyterlab\_pioneer.default_exporters module 5 | -------------------------------------------- 6 | 7 | .. automodule:: jupyterlab_pioneer.default_exporters 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | jupyterlab\_pioneer.handlers module 13 | -------------------------------------------- 14 | 15 | .. automodule:: jupyterlab_pioneer.handlers 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | jupyterlab\_pioneer.application module 21 | -------------------------------------------- 22 | 23 | .. automodule:: jupyterlab_pioneer.application 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | -------------------------------------------------------------------------------- /configuration_examples/.dockerignore: -------------------------------------------------------------------------------- 1 | # Include any files or directories that you don't want to be copied to your 2 | # container here (e.g., local build artifacts, temporary files, etc.). 3 | # 4 | # For more help, visit the .dockerignore file reference guide at 5 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 6 | 7 | **/.DS_Store 8 | **/__pycache__ 9 | **/.venv 10 | **/.classpath 11 | **/.dockerignore 12 | **/.env 13 | **/.git 14 | **/.gitignore 15 | **/.project 16 | **/.settings 17 | **/.toolstarget 18 | **/.vs 19 | **/.vscode 20 | **/*.*proj.user 21 | **/*.dbmdl 22 | **/*.jfm 23 | **/bin 24 | **/charts 25 | **/docker-compose* 26 | **/compose* 27 | **/Dockerfile* 28 | **/node_modules 29 | **/npm-debug.log 30 | **/obj 31 | **/secrets.dev.yaml 32 | **/values.dev.yaml 33 | LICENSE 34 | README.md 35 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: '3.11' 13 | # You can also specify other tool versions: 14 | # nodejs: "19" 15 | # rust: "1.64" 16 | # golang: "1.19" 17 | 18 | # Build documentation in the "docs/" directory with Sphinx 19 | sphinx: 20 | configuration: docs/conf.py 21 | # Optionally build your docs in additional formats such as PDF and ePub 22 | # formats: 23 | # - pdf 24 | # - epub 25 | 26 | # Optional but recommended, declare the Python requirements required 27 | # to build your documentation 28 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 29 | python: 30 | install: 31 | - requirements: docs/requirements.txt 32 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/architecture.rst: -------------------------------------------------------------------------------- 1 | Architecture 2 | ============ 3 | 4 | .. mermaid:: 5 | :zoom: 6 | 7 | sequenceDiagram 8 | actor U as User 9 | participant C as Client extension
(Browser) 10 | participant S as Server extension
(Python kernel) 11 | participant E as console, command line, local file or http endpoint 12 | 13 | C->>S: Request global configuration 14 | S->>C: Send global configuration file content 15 | Note over C: Parse exporter and active events configuration,
override global configuration if notebook's
metadata contains local configuration. 16 | Note over C, S: Extension activated 17 | activate U 18 | U->>C: Interact with Jupyter Lab,
trigger events 19 | C->>S: Send event data & exporter info 20 | activate E 21 | S->>E: Export event data
based on exporter info 22 | S->>C: Send exporter's response message 23 | deactivate U 24 | deactivate E -------------------------------------------------------------------------------- /configuration_examples/file_exporter/jupyter_jupyterlab_pioneer_config.py: -------------------------------------------------------------------------------- 1 | # This file should be saved into one of the config directories provided by `jupyter --path`. 2 | 3 | c.JupyterLabPioneerApp.exporters = [ 4 | { 5 | # writes telemetry data to local file 6 | "type": "file_exporter", 7 | "args": { 8 | "path": "log" 9 | }, 10 | }, 11 | ] 12 | 13 | c.JupyterLabPioneerApp.activeEvents = [ 14 | {"name": "ActiveCellChangeEvent", "logWholeNotebook": False}, 15 | {"name": "CellAddEvent", "logWholeNotebook": False}, 16 | # {"name": "CellEditEvent", "logWholeNotebook": False}, 17 | {"name": "CellExecuteEvent", "logWholeNotebook": False}, 18 | {"name": "CellRemoveEvent", "logWholeNotebook": False}, 19 | # {"name": "ClipboardCopyEvent", "logWholeNotebook": False}, 20 | # {"name": "ClipboardCutEvent", "logWholeNotebook": False}, 21 | # {"name": "ClipboardPasteEvent", "logWholeNotebook": False}, 22 | # {"name": "NotebookHiddenEvent", "logWholeNotebook": False}, 23 | # {"name": "NotebookOpenEvent", "logWholeNotebook": False}, 24 | # {"name": "NotebookSaveEvent", "logWholeNotebook": False}, 25 | # {"name": "NotebookScrollEvent", "logWholeNotebook": False}, 26 | # {"name": "NotebookVisibleEvent", "logWholeNotebook": False}, 27 | ] 28 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import { URLExt } from '@jupyterlab/coreutils'; 2 | 3 | import { ServerConnection } from '@jupyterlab/services'; 4 | 5 | /** 6 | * Call the API extension 7 | * 8 | * @param endPoint API REST end point for the extension 9 | * @param init Initial values for the request 10 | * @returns The response body interpreted as JSON 11 | */ 12 | export async function requestAPI( 13 | endPoint = '', 14 | init: RequestInit = {} 15 | ): Promise { 16 | // Make request to Jupyter API 17 | const settings = ServerConnection.makeSettings(); 18 | const requestUrl = URLExt.join( 19 | settings.baseUrl, 20 | 'jupyterlab-pioneer', // API Namespace 21 | endPoint 22 | ); 23 | 24 | let response: Response; 25 | try { 26 | response = await ServerConnection.makeRequest(requestUrl, init, settings); 27 | } catch (error) { 28 | throw new ServerConnection.NetworkError(error as any); 29 | } 30 | 31 | let data: any = await response.text(); 32 | 33 | if (data.length > 0) { 34 | try { 35 | data = JSON.parse(data); 36 | } catch (error) { 37 | console.log('Not a JSON response body.', response); 38 | } 39 | } 40 | 41 | if (!response.ok) { 42 | throw new ServerConnection.ResponseError(response, data.message || data); 43 | } 44 | 45 | return data; 46 | } 47 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Install 5 | -------- 6 | 7 | To install the extension, execute:: 8 | 9 | Using pip:: 10 | 11 | pip install jupyterlab_pioneer 12 | 13 | Using conda (via conda-forge):: 14 | 15 | conda install -c conda-forge jupyterlab-pioneer 16 | 17 | Before starting Jupyter Lab, users need to write their own configuration files (or use the `provided configuration examples `_) and place them in the `correct directory`_. 18 | 19 | Documentation on how to write configuration files is here_. 20 | 21 | .. _correct directory: configuration.html#configuration-file-name-path 22 | .. _here: configuration.html 23 | 24 | Uninstall 25 | --------- 26 | 27 | To remove the extension, execute:: 28 | 29 | Using pip:: 30 | 31 | pip uninstall jupyterlab_pioneer 32 | 33 | Using conda:: 34 | 35 | conda uninstall jupyterlab-pioneer 36 | 37 | Troubleshoot 38 | ------------ 39 | 40 | If you are seeing the frontend extension, but it is not working, check 41 | that the server extension is enabled:: 42 | 43 | jupyter server extension list 44 | 45 | If the server extension is installed and enabled, but you are not seeing 46 | the frontend extension, check the frontend extension is installed:: 47 | 48 | jupyter labextension list 49 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | JupyterLab Pioneer 2 | =================== 3 | 4 | A JupyterLab extension for generating and exporting JupyterLab event telemetry data. 5 | 6 | Using this extension 7 | --------------------- 8 | 9 | :doc:`quick_start` 10 | Set up JupyterLab Pioneer with docker. 11 | 12 | :doc:`installation` 13 | How to install the extension package and configure manually. 14 | 15 | :doc:`configuration` 16 | How to use the configuration file to control the activated events and data exporters of the extension. 17 | 18 | :doc:`event_library` 19 | A list of all the events that can be activated by the extension. 20 | 21 | Development 22 | --------------------- 23 | 24 | :doc:`architecture` 25 | Architecture diagram 26 | 27 | :doc:`custom_event` 28 | How to implement a custom event extension 29 | 30 | :doc:`contributing` 31 | How to contribute to the extension 32 | 33 | 34 | .. Hidden TOCs 35 | 36 | .. toctree:: 37 | :maxdepth: 1 38 | :numbered: 39 | :caption: Using this extension 40 | :hidden: 41 | 42 | quick_start 43 | installation 44 | configuration 45 | event_library 46 | 47 | .. toctree:: 48 | :maxdepth: 1 49 | :numbered: 50 | :caption: Development 51 | :hidden: 52 | 53 | architecture 54 | custom_event 55 | contributing 56 | 57 | .. toctree:: 58 | :caption: Reference: 59 | :hidden: 60 | 61 | jupyterlab_pioneer 62 | -------------------------------------------------------------------------------- /configuration_examples/console_exporter/jupyter_jupyterlab_pioneer_config.py: -------------------------------------------------------------------------------- 1 | # This file should be saved into one of the config directories provided by `jupyter --path`. 2 | 3 | c.JupyterLabPioneerApp.exporters = [ 4 | { 5 | # sends telemetry data to the browser console 6 | "type": "console_exporter", 7 | }, 8 | { 9 | # sends telemetry data to the python console jupyter is running on 10 | "type": "command_line_exporter", 11 | }, 12 | ] 13 | 14 | c.JupyterLabPioneerApp.activeEvents = [ 15 | {"name": "ActiveCellChangeEvent", "logWholeNotebook": False}, 16 | {"name": "CellAddEvent", "logWholeNotebook": False}, 17 | # {"name": "CellEditEvent", "logWholeNotebook": False}, 18 | {"name": "CellExecuteEvent", "logWholeNotebook": False}, 19 | {"name": "CellRemoveEvent", "logWholeNotebook": False}, 20 | # {"name": "ClipboardCopyEvent", "logWholeNotebook": False}, 21 | # {"name": "ClipboardCutEvent", "logWholeNotebook": False}, 22 | # {"name": "ClipboardPasteEvent", "logWholeNotebook": False}, 23 | # {"name": "NotebookHiddenEvent", "logWholeNotebook": False}, 24 | # {"name": "NotebookOpenEvent", "logWholeNotebook": False}, 25 | # {"name": "NotebookSaveEvent", "logWholeNotebook": False}, 26 | # {"name": "NotebookScrollEvent", "logWholeNotebook": False}, 27 | # {"name": "NotebookVisibleEvent", "logWholeNotebook": False}, 28 | ] -------------------------------------------------------------------------------- /configuration_examples/custom_exporter/jupyter_jupyterlab_pioneer_config.py: -------------------------------------------------------------------------------- 1 | # This file should be saved into one of the config directories provided by `jupyter --path`. 2 | 3 | def my_custom_exporter(args): 4 | # write your own exporter logic here 5 | return { 6 | "exporter": args.get("id"), 7 | "message": "" 8 | } 9 | 10 | c.JupyterLabPioneerApp.exporters = [ 11 | { 12 | "type": "custom_exporter", 13 | "args": { 14 | "id": "MyCustomExporter" 15 | # add additional args for your exporter function here 16 | }, 17 | } 18 | ] 19 | 20 | c.JupyterLabPioneerApp.custom_exporter = { 21 | 'MyCustomExporter': my_custom_exporter, 22 | } 23 | 24 | c.JupyterLabPioneerApp.activeEvents = [ 25 | {"name": "ActiveCellChangeEvent", "logWholeNotebook": False}, 26 | {"name": "CellAddEvent", "logWholeNotebook": False}, 27 | # {"name": "CellEditEvent", "logWholeNotebook": False}, 28 | {"name": "CellExecuteEvent", "logWholeNotebook": False}, 29 | {"name": "CellRemoveEvent", "logWholeNotebook": False}, 30 | # {"name": "ClipboardCopyEvent", "logWholeNotebook": False}, 31 | # {"name": "ClipboardCutEvent", "logWholeNotebook": False}, 32 | # {"name": "ClipboardPasteEvent", "logWholeNotebook": False}, 33 | # {"name": "NotebookHiddenEvent", "logWholeNotebook": False}, 34 | # {"name": "NotebookOpenEvent", "logWholeNotebook": False}, 35 | # {"name": "NotebookSaveEvent", "logWholeNotebook": False}, 36 | # {"name": "NotebookScrollEvent", "logWholeNotebook": False}, 37 | # {"name": "NotebookVisibleEvent", "logWholeNotebook": False}, 38 | ] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Educational Technology Collective 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /configuration_examples/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use python 3.11.5-slim as base image 2 | ARG PYTHON_VERSION=3.11.5 3 | FROM python:${PYTHON_VERSION}-slim as base 4 | 5 | # Prevents Python from writing pyc files. 6 | ENV PYTHONDONTWRITEBYTECODE=1 7 | 8 | # Keeps Python from buffering stdout and stderr to avoid situations where 9 | # the application crashes without emitting any logs due to buffering. 10 | ENV PYTHONUNBUFFERED=1 11 | 12 | # Prevent the pipeline from succeeding if any of the commands fail 13 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] 14 | 15 | # Set user to root 16 | USER root 17 | 18 | # Install dependencies 19 | RUN apt-get update --yes && \ 20 | apt-get install --yes --no-install-recommends \ 21 | curl \ 22 | gcc \ 23 | python3-dev && \ 24 | apt-get clean && rm -rf /var/lib/apt/lists/* 25 | 26 | RUN python3.11 -m pip install --no-cache-dir --upgrade pip \ 27 | && python3.11 -m pip install -U setuptools \ 28 | && python3.11 -m pip install jupyterlab \ 29 | && python3.11 -m pip install -U jupyterlab_pioneer 30 | 31 | # Copy the jupyterlab-pioneer configuration example file to one of the jupyter config directories 32 | # COPY all_exporters/jupyter_jupyterlab_pioneer_config.py /etc/jupyter/ 33 | COPY console_exporter/jupyter_jupyterlab_pioneer_config.py /etc/jupyter/ 34 | # COPY file_exporter/jupyter_jupyterlab_pioneer_config.py /etc/jupyter/ 35 | # COPY remote_exporter/jupyter_jupyterlab_pioneer_config.py /etc/jupyter/ 36 | # COPY custom_exporter/jupyter_jupyterlab_pioneer_config.py /etc/jupyter/ 37 | 38 | # Run jupyter lab, give access to all ips so that jupyter lab could be accessed outside of the docker container, disable browser and allow root access 39 | CMD ["jupyter-lab","--ip=0.0.0.0","--no-browser","--allow-root"] 40 | -------------------------------------------------------------------------------- /.github/workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | # silent: 16 | # description: "Set a placeholder in the changelog and don't publish the release." 17 | # required: false 18 | # type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | jobs: 27 | prep_release: 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: write 31 | steps: 32 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 33 | 34 | - name: Prep Release 35 | id: prep-release 36 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | version_spec: ${{ github.event.inputs.version_spec }} 40 | # silent: ${{ github.event.inputs.silent }} 41 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 42 | branch: ${{ github.event.inputs.branch }} 43 | since: ${{ github.event.inputs.since }} 44 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 45 | 46 | - name: "** Next Step **" 47 | run: | 48 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" -------------------------------------------------------------------------------- /docs/event_library.rst: -------------------------------------------------------------------------------- 1 | Event Library 2 | ============= 3 | 4 | .. list-table:: 5 | :widths: 25 25 50 6 | :header-rows: 1 7 | 8 | * - Event Name 9 | - Event is triggered when 10 | - Event Data (except for event name and time) 11 | * - ActiveCellChangeEvent 12 | - user moves focus to a different cell 13 | - activated cell id and index 14 | * - CellAddEvent 15 | - a new cell is added 16 | - added cell id and index 17 | * - CellEditEvent 18 | - user moves focus to a different cell 19 | - cell index, the codemirror editor content of the cell 20 | * - CellEditEvent 21 | - user edits a cell 22 | - cell index, the codemirror editor changeset of the cell 23 | * - CellExecuteEvent 24 | - a cell is executed 25 | - executed cell id and index, a boolean indicates success or failure, kernel error detail if execution failed 26 | * - CellRemoveEvent 27 | - a cell is removed 28 | - removed cell id and index 29 | * - ClipboardCopyEvent 30 | - user copies from a notebook cell 31 | - id and index of the cell the user copies from, text copied 32 | * - ClipboardCutEvent 33 | - user cuts from a notebook cell 34 | - id and index of the cell the user cuts from, text cut 35 | * - ClipboardPasteEvent 36 | - user pastes to a notebook cell 37 | - id and index of the cell the user pastes to, text copied 38 | * - NotebookHiddenEvent 39 | - user leaves the Jupyter Lab tab 40 | - 41 | * - NotebookOpenEvent 42 | - a notebook is opened 43 | - environment variables 44 | * - NotebookSaveEvent 45 | - a notebook is saved 46 | - 47 | * - NotebookScrollEvent 48 | - user scrolls on the notebook 49 | - visible cells after scrolling 50 | * - NotebookVisibleEvent 51 | - user navigates back to Jupyter Lab 52 | - visible cells when user navigates back -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": ["README.md"], 3 | "imageSize": 100, 4 | "commit": false, 5 | "commitType": "docs", 6 | "commitConvention": "angular", 7 | "contributors": [ 8 | { 9 | "login": "mengyanw", 10 | "name": "mengyanw", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/85606983?v=4", 12 | "profile": "https://github.com/mengyanw", 13 | "contributions": [ 14 | "code", 15 | "ideas", 16 | "maintenance", 17 | "projectManagement", 18 | "infra", 19 | "test", 20 | "doc" 21 | ] 22 | }, 23 | { 24 | "login": "cab938", 25 | "name": "Christopher Brooks", 26 | "avatar_url": "https://avatars.githubusercontent.com/u/1355641?v=4", 27 | "profile": "http://christopherbrooks.ca", 28 | "contributions": ["ideas", "projectManagement", "infra"] 29 | }, 30 | { 31 | "login": "soney", 32 | "name": "Steve Oney", 33 | "avatar_url": "https://avatars.githubusercontent.com/u/211262?v=4", 34 | "profile": "http://from.so/Steve_Oney", 35 | "contributions": ["ideas", "projectManagement"] 36 | }, 37 | { 38 | "login": "costrouc", 39 | "name": "Christopher Ostrouchov", 40 | "avatar_url": "https://avatars.githubusercontent.com/u/1740337?v=4", 41 | "profile": "https://www.chrisostrouchov.com", 42 | "contributions": ["ideas", "code"] 43 | }, 44 | { 45 | "login": "aktech", 46 | "name": "Amit Kumar", 47 | "avatar_url": "https://avatars.githubusercontent.com/u/5647941?v=4", 48 | "profile": "https://iamit.in", 49 | "contributions": ["infra"] 50 | } 51 | ], 52 | "contributorsPerLine": 7, 53 | "skipCi": true, 54 | "repoType": "github", 55 | "repoHost": "https://github.com", 56 | "projectName": "jupyterlab-pioneer", 57 | "projectOwner": "educational-technology-collective" 58 | } 59 | -------------------------------------------------------------------------------- /src/types.tsx: -------------------------------------------------------------------------------- 1 | export interface ActiveEvent { 2 | /** 3 | * Name of the active event (the static id associate with the event class) 4 | */ 5 | name: string; 6 | /** 7 | * Whether to log the whole notebook or not when the event is triggered 8 | */ 9 | logWholeNotebook: boolean; 10 | /** 11 | * Whether to log the whole notebook or not when the event is triggered 12 | */ 13 | logCellMetadata: boolean; 14 | } 15 | export interface ExporterArgs { 16 | /** 17 | * Exporter ID 18 | */ 19 | id: string; 20 | /** 21 | * Local file path (required for file exporter) 22 | */ 23 | path?: string; 24 | /** 25 | * Http endpoint (required for remote exporter) 26 | */ 27 | url?: string; 28 | /** 29 | * Additional parameters to pass to the http endpoint (optional for remote exporter) 30 | */ 31 | params?: object; 32 | /** 33 | * Environment variables to pass to the http endpoint (optional for remote exporter) 34 | */ 35 | env?: object[]; 36 | } 37 | 38 | export interface Exporter { 39 | /** 40 | * Exporter type, should be one of "console_exporter", "command_line_exporter", "file_exporter", "remote_exporter", "opentelemetry_exporter" or "custom_exporter" 41 | */ 42 | type: string; 43 | /** 44 | * Arguments to pass to the exporter function 45 | */ 46 | args?: ExporterArgs; 47 | /** 48 | * An array of active events defined inside of individual exporters. It overrides the global activeEvents configuration defined in the configuration file. 49 | * The extension would only generate and export data for valid events ( 1. that have an id associated with the event class, 2. the event name is included in activeEvents ). 50 | * The extension will export the entire notebook content only for valid events when the logWholeNotebook flag is True. 51 | */ 52 | activeEvents?: ActiveEvent[]; 53 | } 54 | 55 | export interface Config { 56 | /** 57 | * An array of active events 58 | */ 59 | activeEvents: ActiveEvent[]; 60 | /** 61 | * An array of exporters 62 | */ 63 | exporters: Exporter[]; 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | environment: release 19 | permissions: 20 | id-token: write 21 | steps: 22 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - uses: actions/create-github-app-token@v1 25 | id: app-token 26 | with: 27 | app-id: ${{ vars.APP_ID }} 28 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 29 | 30 | - name: Populate Release 31 | id: populate-release 32 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 33 | with: 34 | token: ${{ steps.app-token.outputs.token }} 35 | branch: ${{ github.event.inputs.branch }} 36 | release_url: ${{ github.event.inputs.release_url }} 37 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 38 | 39 | - name: Finalize Release 40 | id: finalize-release 41 | env: 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 44 | with: 45 | token: ${{ steps.app-token.outputs.token }} 46 | release_url: ${{ steps.populate-release.outputs.release_url }} 47 | 48 | - name: "** Next Step **" 49 | if: ${{ success() }} 50 | run: | 51 | echo "Verify the final release" 52 | echo ${{ steps.finalize-release.outputs.release_url }} 53 | - name: "** Failure Message **" 54 | if: ${{ failure() }} 55 | run: | 56 | echo "Failed to Publish the Draft Release Url:" 57 | echo ${{ steps.populate-release.outputs.release_url }} -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | sys.path.insert(0, os.path.abspath(".")) 5 | sys.path.insert(0, os.path.abspath('../..')) 6 | 7 | # Configuration file for the Sphinx documentation builder. 8 | # 9 | # For the full list of built-in configuration values, see the documentation: 10 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 11 | 12 | # -- Project information ----------------------------------------------------- 13 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 14 | 15 | project = 'jupyterlab-pioneer' 16 | copyright = '2023, University of Michigan' 17 | author = 'University of Michigan' 18 | 19 | # -- General configuration --------------------------------------------------- 20 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 21 | 22 | extensions = [ 23 | 'sphinx.ext.autodoc', 24 | 'sphinx.ext.viewcode', 25 | 'sphinx.ext.todo', 26 | 'sphinx.ext.napoleon', 27 | 'sphinx_rtd_theme', 28 | 'sphinxcontrib.mermaid' 29 | ] 30 | 31 | templates_path = ['_templates'] 32 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 33 | 34 | language = 'en' 35 | 36 | # -- Options for HTML output ------------------------------------------------- 37 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 38 | 39 | html_theme = 'sphinx_rtd_theme' 40 | html_theme_options = { 41 | 'analytics_anonymize_ip': False, 42 | 'logo_only': False, 43 | 'display_version': True, 44 | 'prev_next_buttons_location': 'bottom', 45 | 'style_external_links': False, 46 | 'vcs_pageview_mode': '', 47 | 'style_nav_header_background': 'white', 48 | # Toc options 49 | 'collapse_navigation': True, 50 | 'sticky_navigation': True, 51 | 'navigation_depth': 4, 52 | 'includehidden': True, 53 | 'titles_only': False 54 | } 55 | html_static_path = ['_static'] 56 | 57 | # -- Options for todo extension ---------------------------------------------- 58 | # https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration 59 | 60 | todo_include_todos = True 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.log 5 | .eslintcache 6 | .stylelintcache 7 | *.egg-info/ 8 | .ipynb_checkpoints 9 | *.tsbuildinfo 10 | jupyterlab_pioneer/labextension 11 | # Version file is handled by hatchling 12 | # jupyterlab_pioneer/_version.py 13 | 14 | # Created by https://www.gitignore.io/api/python 15 | # Edit at https://www.gitignore.io/?templates=python 16 | 17 | ### Python ### 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | pip-wheel-metadata/ 41 | share/python-wheels/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage/ 65 | coverage.xml 66 | *.cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | 70 | # Translations 71 | *.mo 72 | *.pot 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # SageMath parsed files 90 | *.sage.py 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # Mr Developer 100 | .mr.developer.cfg 101 | .project 102 | .pydevproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | .dmypy.json 110 | dmypy.json 111 | 112 | # Pyre type checker 113 | .pyre/ 114 | 115 | # End of https://www.gitignore.io/api/python 116 | 117 | # OSX files 118 | .DS_Store 119 | 120 | # Yarn cache 121 | .yarn/ 122 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============= 3 | 4 | Development install 5 | -------------------- 6 | 7 | **(Optional) create conda environment from the provided `environment.yml` file** 8 | :: 9 | 10 | # Clone the repo to your local environment 11 | # Change directory to the jupyterlab-pioneer directory 12 | conda env create -f environment.yml 13 | 14 | **Clone and build the extension package** 15 | 16 | Note: You will need NodeJS to build the extension package. 17 | 18 | The `jlpm` command is JupyterLab's pinned version of 19 | yarn that is installed with JupyterLab. You may use 20 | `yarn` or `npm` in lieu of `jlpm` below. 21 | :: 22 | 23 | # Clone the repo to your local environment 24 | # Change directory to the jupyterlab-pioneer directory 25 | # Install package in development mode 26 | pip install -e "." 27 | # Link your development version of the extension with JupyterLab 28 | jupyter labextension develop . --overwrite 29 | # Server extension must be manually installed in develop mode 30 | jupyter server extension enable jupyterlab_pioneer 31 | # Rebuild extension Typescript source after making changes 32 | jlpm build 33 | 34 | You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension. 35 | :: 36 | 37 | # Watch the source directory in one terminal, automatically rebuilding when needed 38 | jlpm watch 39 | # Run JupyterLab in another terminal 40 | jupyter lab 41 | 42 | With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). 43 | 44 | By default, the `jlpm build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command: 45 | :: 46 | 47 | jupyter lab build --minimize=False 48 | 49 | Development uninstall 50 | --------------------- 51 | 52 | :: 53 | 54 | # Server extension must be manually disabled in develop mode 55 | jupyter server extension disable jupyterlab_pioneer 56 | pip uninstall jupyterlab_pioneer 57 | 58 | In development mode, you will also need to remove the symlink created by `jupyter labextension develop` 59 | command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions` 60 | folder is located. Then you can remove the symlink named `jupyterlab-pioneer` within that folder. 61 | 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.5.0", "jupyterlab>=4.1.0,<5", "hatch-nodejs-version"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jupyterlab_pioneer" 7 | readme = "README.md" 8 | license = { file = "LICENSE" } 9 | requires-python = ">=3.8" 10 | classifiers = [ 11 | "Framework :: Jupyter", 12 | "Framework :: Jupyter :: JupyterLab", 13 | "Framework :: Jupyter :: JupyterLab :: 4", 14 | "Framework :: Jupyter :: JupyterLab :: Extensions", 15 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 16 | "License :: OSI Approved :: BSD License", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | ] 24 | dependencies = [ 25 | "jupyter_server>=2.0.1,<3" 26 | ] 27 | dynamic = ["version", "description", "authors", "urls", "keywords"] 28 | 29 | [tool.hatch.version] 30 | source = "nodejs" 31 | 32 | [tool.hatch.metadata.hooks.nodejs] 33 | fields = ["description", "authors", "urls"] 34 | 35 | [tool.hatch.build.targets.sdist] 36 | artifacts = ["jupyterlab_pioneer/labextension"] 37 | exclude = [".github", "binder"] 38 | 39 | [tool.hatch.build.targets.wheel.shared-data] 40 | "jupyterlab_pioneer/labextension" = "share/jupyter/labextensions/jupyterlab-pioneer" 41 | "install.json" = "share/jupyter/labextensions/jupyterlab-pioneer/install.json" 42 | "jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d" 43 | 44 | [tool.hatch.build.hooks.version] 45 | path = "jupyterlab_pioneer/_version.py" 46 | 47 | [tool.hatch.build.hooks.jupyter-builder] 48 | dependencies = ["hatch-jupyter-builder>=0.5"] 49 | build-function = "hatch_jupyter_builder.npm_builder" 50 | ensured-targets = [ 51 | "jupyterlab_pioneer/labextension/static/style.js", 52 | "jupyterlab_pioneer/labextension/package.json", 53 | ] 54 | skip-if-exists = ["jupyterlab_pioneer/labextension/static/style.js"] 55 | 56 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 57 | build_cmd = "build:prod" 58 | npm = ["jlpm"] 59 | 60 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 61 | build_cmd = "install:extension" 62 | npm = ["jlpm"] 63 | source_dir = "src" 64 | build_dir = "jupyterlab_pioneer/labextension" 65 | 66 | [tool.jupyter-releaser.options] 67 | version_cmd = "hatch version" 68 | 69 | [tool.jupyter-releaser.hooks] 70 | before-build-npm = [ 71 | "python -m pip install 'jupyterlab>=4.1.0,<5'", 72 | "jlpm", 73 | "jlpm build:prod" 74 | ] 75 | before-build-python = ["jlpm clean:all"] 76 | 77 | [tool.check-wheel-contents] 78 | ignore = ["W002"] 79 | -------------------------------------------------------------------------------- /configuration_examples/remote_exporter/jupyter_jupyterlab_pioneer_config.py: -------------------------------------------------------------------------------- 1 | # This file should be saved into one of the config directories provided by `jupyter --path`. 2 | 3 | c.JupyterLabPioneerApp.exporters = [ 4 | { 5 | # sends telemetry data to a remote http endpoint (AWS S3 bucket) 6 | "type": "remote_exporter", 7 | "args": { 8 | "id": "S3Exporter", 9 | "url": "https://telemetry.mentoracademy.org/telemetry-edtech-labs-si-umich-edu/dev/test-telemetry", 10 | "env": ["WORKSPACE_ID"], 11 | }, 12 | }, 13 | # { 14 | # # sends telemetry data to a remote http endpoint (an AWS Lambda function that exports data to MongoDB) 15 | # "type": "remote_exporter", 16 | # "args": { 17 | # "id": "MongoDBLambdaExporter", 18 | # "url": "https://68ltdi5iij.execute-api.us-east-1.amazonaws.com/mongo", 19 | # "params": { 20 | # "mongo_cluster": "mengyanclustertest.6b83fsy.mongodb.net", 21 | # "mongo_db": "telemetry", 22 | # "mongo_collection": "dev", 23 | # }, 24 | # "env": ["WORKSPACE_ID"], 25 | # }, 26 | # }, 27 | { 28 | # sends telemetry data to a remote http endpoint (an AWS Lambda function that exports data to InfluxDB) 29 | "type": "remote_exporter", 30 | "args": { 31 | "id": "InfluxDBLambdaExporter", 32 | "url": "https://68ltdi5iij.execute-api.us-east-1.amazonaws.com/influx", 33 | "params": { 34 | "influx_bucket": "telemetry_dev", 35 | "influx_measurement": "si101_fa24", 36 | }, 37 | }, 38 | "activeEvents": [ 39 | {"name": "CellEditEvent", "logWholeNotebook": False}, 40 | ], # exporter's local active_events config will override global activeEvents config 41 | }, 42 | ] 43 | 44 | c.JupyterLabPioneerApp.activeEvents = [ 45 | {"name": "ActiveCellChangeEvent", "logWholeNotebook": False}, 46 | {"name": "CellAddEvent", "logWholeNotebook": False}, 47 | # {"name": "CellEditEvent", "logWholeNotebook": False}, 48 | {"name": "CellExecuteEvent", "logWholeNotebook": False}, 49 | {"name": "CellRemoveEvent", "logWholeNotebook": False}, 50 | # {"name": "ClipboardCopyEvent", "logWholeNotebook": False}, 51 | # {"name": "ClipboardCutEvent", "logWholeNotebook": False}, 52 | # {"name": "ClipboardPasteEvent", "logWholeNotebook": False}, 53 | # {"name": "NotebookHiddenEvent", "logWholeNotebook": False}, 54 | # {"name": "NotebookOpenEvent", "logWholeNotebook": False}, 55 | # {"name": "NotebookSaveEvent", "logWholeNotebook": False}, 56 | # {"name": "NotebookScrollEvent", "logWholeNotebook": False}, 57 | # {"name": "NotebookVisibleEvent", "logWholeNotebook": False}, 58 | ] 59 | -------------------------------------------------------------------------------- /.github/workflows/update-integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Update Playwright Snapshots 2 | 3 | on: 4 | issue_comment: 5 | types: [created, edited] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | update-snapshots: 13 | if: > 14 | ( 15 | github.event.issue.author_association == 'OWNER' || 16 | github.event.issue.author_association == 'COLLABORATOR' || 17 | github.event.issue.author_association == 'MEMBER' 18 | ) && github.event.issue.pull_request && contains(github.event.comment.body, 'please update snapshots') 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: React to the triggering comment 23 | run: | 24 | gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions --raw-field 'content=+1' 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | with: 31 | token: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Get PR Info 34 | id: pr 35 | env: 36 | PR_NUMBER: ${{ github.event.issue.number }} 37 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | GH_REPO: ${{ github.repository }} 39 | COMMENT_AT: ${{ github.event.comment.created_at }} 40 | run: | 41 | pr="$(gh api /repos/${GH_REPO}/pulls/${PR_NUMBER})" 42 | head_sha="$(echo "$pr" | jq -r .head.sha)" 43 | pushed_at="$(echo "$pr" | jq -r .pushed_at)" 44 | 45 | if [[ $(date -d "$pushed_at" +%s) -gt $(date -d "$COMMENT_AT" +%s) ]]; then 46 | echo "Updating is not allowed because the PR was pushed to (at $pushed_at) after the triggering comment was issued (at $COMMENT_AT)" 47 | exit 1 48 | fi 49 | 50 | echo "head_sha=$head_sha" >> $GITHUB_OUTPUT 51 | 52 | - name: Checkout the branch from the PR that triggered the job 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | run: gh pr checkout ${{ github.event.issue.number }} 56 | 57 | - name: Validate the fetched branch HEAD revision 58 | env: 59 | EXPECTED_SHA: ${{ steps.pr.outputs.head_sha }} 60 | run: | 61 | actual_sha="$(git rev-parse HEAD)" 62 | 63 | if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then 64 | echo "The HEAD of the checked out branch ($actual_sha) differs from the HEAD commit available at the time when trigger comment was submitted ($EXPECTED_SHA)" 65 | exit 1 66 | fi 67 | 68 | - name: Base Setup 69 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 70 | 71 | - name: Install dependencies 72 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 73 | 74 | - name: Install extension 75 | run: | 76 | set -eux 77 | jlpm 78 | python -m pip install . 79 | 80 | - uses: jupyterlab/maintainer-tools/.github/actions/update-snapshots@v1 81 | with: 82 | github_token: ${{ secrets.GITHUB_TOKEN }} 83 | # Playwright knows how to start JupyterLab server 84 | start_server_script: 'null' 85 | test_folder: ui-tests 86 | npm_client: jlpm -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | ## 1.7.1 6 | 7 | No merged PRs 8 | 9 | 10 | 11 | ## 1.7.0 12 | 13 | ([Full Changelog](https://github.com/educational-technology-collective/jupyterlab-pioneer/compare/68495f1461e20eaa5aaf9973ea4c23d426a30885...d6c9fdfc24df7f83b0a267c01797d9442d594c10)) 14 | 15 | ### Merged PRs 16 | 17 | - docs: add cmarmo as a contributor for code, and infra [#44](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/44) ([@allcontributors](https://github.com/allcontributors)) 18 | - Add relevant github workflows [#43](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/43) ([@cmarmo](https://github.com/cmarmo)) 19 | - Added instructions to install from conda as well. [#40](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/40) ([@isumitjha](https://github.com/isumitjha)) 20 | - docs: add aktech as a contributor for infra [#36](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/36) ([@allcontributors](https://github.com/allcontributors)) 21 | - docs: add costrouc as a contributor for ideas, and code [#35](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/35) ([@allcontributors](https://github.com/allcontributors)) 22 | - docs: add soney as a contributor for ideas, projectManagement, and infra [#34](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/34) ([@allcontributors](https://github.com/allcontributors)) 23 | - docs: add cab938 as a contributor for ideas, projectManagement, and infra [#33](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/33) ([@allcontributors](https://github.com/allcontributors)) 24 | - docs: add mengyanw as a contributor for code, ideas, and 5 more [#32](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/32) ([@allcontributors](https://github.com/allcontributors)) 25 | - Adding opentelemetry exporter [#29](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/29) ([@costrouc](https://github.com/costrouc)) 26 | - feat: event filter #10 [#22](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/22) ([@mengyanw](https://github.com/mengyanw)) 27 | - feat: #11 cell edit event [#20](https://github.com/educational-technology-collective/jupyterlab-pioneer/pull/20) ([@mengyanw](https://github.com/mengyanw)) 28 | 29 | ### Contributors to this release 30 | 31 | ([GitHub contributors page for this release](https://github.com/educational-technology-collective/jupyterlab-pioneer/graphs/contributors?from=2023-09-12&to=2025-06-13&type=c)) 32 | 33 | [@allcontributors](https://github.com/search?q=repo%3Aeducational-technology-collective%2Fjupyterlab-pioneer+involves%3Aallcontributors+updated%3A2023-09-12..2025-06-13&type=Issues) | [@cmarmo](https://github.com/search?q=repo%3Aeducational-technology-collective%2Fjupyterlab-pioneer+involves%3Acmarmo+updated%3A2023-09-12..2025-06-13&type=Issues) | [@costrouc](https://github.com/search?q=repo%3Aeducational-technology-collective%2Fjupyterlab-pioneer+involves%3Acostrouc+updated%3A2023-09-12..2025-06-13&type=Issues) | [@isumitjha](https://github.com/search?q=repo%3Aeducational-technology-collective%2Fjupyterlab-pioneer+involves%3Aisumitjha+updated%3A2023-09-12..2025-06-13&type=Issues) | [@mengyanw](https://github.com/search?q=repo%3Aeducational-technology-collective%2Fjupyterlab-pioneer+involves%3Amengyanw+updated%3A2023-09-12..2025-06-13&type=Issues) 34 | -------------------------------------------------------------------------------- /src/utils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Dialog, showDialog, Notification } from '@jupyterlab/apputils'; 3 | import { JupyterFrontEnd } from '@jupyterlab/application'; 4 | import { IMainMenu } from '@jupyterlab/mainmenu'; 5 | import { Exporter } from './types'; 6 | 7 | export const sendInfoNotification = ( 8 | exporters: Exporter[], 9 | isGlobal: boolean 10 | ) => { 11 | const exporterMessage = exporters 12 | .map(each => each.args?.id || each.type) 13 | .join(' & '); 14 | let message; 15 | if (isGlobal && exporterMessage) { 16 | message = `Telemetry data is being logged to ${exporterMessage} through jupyterlab-pioneer. \n See Help menu -> JupyterLab Pioneer for more details.`; 17 | } else if (isGlobal && !exporterMessage) { 18 | message = 19 | 'Telemetry data is being logged through jupyterlab-pioneer. \n See Help menu -> JupyterLab Pioneer for more details.'; 20 | } else { 21 | message = `Embedded telemetry settings loaded. Telemetry data is being logged to ${exporterMessage} now.`; 22 | } 23 | Notification.info(message, { autoClose: 20000 }); 24 | }; 25 | 26 | export const addInfoToHelpMenu = ( 27 | app: JupyterFrontEnd, 28 | mainMenu: IMainMenu, 29 | version: string 30 | ) => { 31 | // Add extension info to help menu 32 | app.commands.addCommand('help:pioneer', { 33 | label: 'JupyterLab Pioneer', 34 | execute: () => { 35 | // Create the header of the dialog 36 | const title = ( 37 | 38 | JupyterLab Pioneer 39 |
40 | 41 | Version {version} 42 | 43 |
44 |
45 | ); 46 | 47 | // Create the body of the dialog 48 | const contributorsURL = 49 | 'https://github.com/educational-technology-collective/jupyterlab-pioneer/graphs/contributors'; 50 | const docURL = 'https://jupyterlab-pioneer.readthedocs.io/en/latest/'; 51 | const gitURL = 52 | 'https://github.com/educational-technology-collective/jupyterlab-pioneer'; 53 | const externalLinks = ( 54 | 55 | 61 | CONTRIBUTOR LIST 62 | 63 | 69 | DOCUMENTATION 70 | 71 | 77 | GITHUB REPO 78 | 79 | 80 | ); 81 | const copyright = ( 82 | 83 | © 2023 Educational Technology Collective 84 | 85 | ); 86 | const body = ( 87 |
88 | {externalLinks} 89 | {copyright} 90 |
91 | ); 92 | 93 | return showDialog({ 94 | title, 95 | body, 96 | buttons: [ 97 | Dialog.createButton({ 98 | label: 'Dismiss', 99 | className: 'jp-About-button jp-mod-reject jp-mod-styled' 100 | }) 101 | ] 102 | }); 103 | } 104 | }); 105 | 106 | mainMenu.helpMenu.addGroup([{ command: 'help:pioneer' }]); 107 | }; 108 | -------------------------------------------------------------------------------- /configuration_examples/all_exporters/jupyter_jupyterlab_pioneer_config.py: -------------------------------------------------------------------------------- 1 | # This file should be saved into one of the config directories provided by `jupyter --path`. 2 | 3 | """An array of the exporters. 4 | 5 | This extension provides 5 default exporters: 6 | `console_exporter`, which sends telemetry data to the browser console. 7 | `command_line_exporter`, which sends telemetry data to the python console jupyter is running on. 8 | `file_exporter`, which saves telemetry data to local file. 9 | `remote_exporter`, which sends telemetry data to a remote http endpoint. 10 | `opentelemetry_exporter`, which sends telemetry data via otlp. 11 | 12 | Additionally, users can import default exporters or write customized exporters in the configuration file. 13 | """ 14 | c.JupyterLabPioneerApp.exporters = [ 15 | { 16 | # sends telemetry data to the browser console 17 | "type": "console_exporter", 18 | }, 19 | { 20 | # sends telemetry data to the python console jupyter is running on 21 | "type": "command_line_exporter", 22 | }, 23 | { 24 | # writes telemetry data to local file 25 | "type": "file_exporter", 26 | "args": { 27 | "path": "log" 28 | }, 29 | }, 30 | { 31 | # sends telemetry data to a remote http endpoint (AWS S3 bucket) 32 | "type": "remote_exporter", 33 | "args": { 34 | "id": "S3Exporter", 35 | "url": "https://telemetry.mentoracademy.org/telemetry-edtech-labs-si-umich-edu/dev/test-telemetry", 36 | "env": ["WORKSPACE_ID"], 37 | }, 38 | }, 39 | { 40 | # sends telemetry data to a remote http endpoint (an AWS Lambda function that exports data to MongoDB) 41 | "type": "remote_exporter", 42 | "args": { 43 | "id": "MongoDBLambdaExporter", 44 | "url": "https://68ltdi5iij.execute-api.us-east-1.amazonaws.com/mongo", 45 | "params": { 46 | "mongo_cluster": "mengyanclustertest.6b83fsy.mongodb.net", 47 | "mongo_db": "telemetry", 48 | "mongo_collection": "dev", 49 | }, 50 | "env": ["WORKSPACE_ID"], 51 | }, 52 | }, 53 | { 54 | # sends telemetry data to a remote http endpoint (an AWS Lambda function that exports data to InfluxDB) 55 | "type": "remote_exporter", 56 | "args": { 57 | "id": "InfluxDBLambdaExporter", 58 | "url": "https://68ltdi5iij.execute-api.us-east-1.amazonaws.com/influx", 59 | "params": { 60 | "influx_bucket": "telemetry_dev", 61 | "influx_measurement": "si101_fa24", 62 | }, 63 | }, 64 | "activeEvents": [ 65 | {"name": "CellEditEvent", "logWholeNotebook": False}, 66 | ], # exporter's local active_events config will override global activeEvents config 67 | }, 68 | ] 69 | 70 | """An array of active events. 71 | This is a global config. It could be override by `activeEvents` defined inside of individual exporter configs 72 | The extension would only generate and export data for valid events ( 73 | 1. that have an id associated with the event class, 74 | 2. the event name is included in `activeEvents` 75 | ). 76 | The extension will export the entire notebook content only for valid events with the logWholeNotebook flag == True 77 | """ 78 | c.JupyterLabPioneerApp.activeEvents = [ 79 | {"name": "ActiveCellChangeEvent", "logWholeNotebook": False}, 80 | {"name": "CellAddEvent", "logWholeNotebook": False}, 81 | # {"name": "CellEditEvent", "logWholeNotebook": False}, 82 | {"name": "CellExecuteEvent", "logWholeNotebook": False}, 83 | {"name": "CellRemoveEvent", "logWholeNotebook": False}, 84 | # {"name": "ClipboardCopyEvent", "logWholeNotebook": False}, 85 | # {"name": "ClipboardCutEvent", "logWholeNotebook": False}, 86 | # {"name": "ClipboardPasteEvent", "logWholeNotebook": False}, 87 | # {"name": "NotebookHiddenEvent", "logWholeNotebook": False}, 88 | # {"name": "NotebookOpenEvent", "logWholeNotebook": False}, 89 | # {"name": "NotebookSaveEvent", "logWholeNotebook": False}, 90 | # {"name": "NotebookScrollEvent", "logWholeNotebook": False}, 91 | # {"name": "NotebookVisibleEvent", "logWholeNotebook": False}, 92 | ] 93 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release of jupyterlab_pioneer 2 | 3 | The extension can be published to `PyPI` and `npm` manually or using the [Jupyter Releaser](https://github.com/jupyter-server/jupyter_releaser). 4 | 5 | ## Manual release 6 | 7 | ### Python package 8 | 9 | This extension can be distributed as Python packages. All of the Python 10 | packaging instructions are in the `pyproject.toml` file to wrap your extension in a 11 | Python package. Before generating a package, you first need to install some tools: 12 | 13 | ```bash 14 | pip install build twine hatch 15 | ``` 16 | 17 | Bump the version using `hatch`. By default this will create a tag. 18 | See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details. 19 | 20 | ```bash 21 | hatch version 22 | ``` 23 | 24 | Make sure to clean up all the development files before building the package: 25 | 26 | ```bash 27 | jlpm clean:all 28 | ``` 29 | 30 | You could also clean up the local git repository: 31 | 32 | ```bash 33 | git clean -dfX 34 | ``` 35 | 36 | To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do: 37 | 38 | ```bash 39 | python -m build 40 | ``` 41 | 42 | > `python setup.py sdist bdist_wheel` is deprecated and will not work for this package. 43 | 44 | Then to upload the package to PyPI, do: 45 | 46 | ```bash 47 | twine upload dist/* 48 | ``` 49 | 50 | ### NPM package 51 | 52 | To publish the frontend part of the extension as a NPM package, do: 53 | 54 | ```bash 55 | npm login 56 | npm publish --access public 57 | ``` 58 | 59 | ## Automated releases with the Jupyter Releaser 60 | 61 | The extension repository should already be compatible with the Jupyter Releaser. 62 | 63 | Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html) for more information. 64 | 65 | Here is a summary of the steps to cut a new release: 66 | 67 | - Add tokens to the [Github Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) in the repository: 68 | - `ADMIN_GITHUB_TOKEN` (with "public_repo" and "repo:status" permissions); see the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) 69 | - `NPM_TOKEN` (with "automation" permission); see the [documentation](https://docs.npmjs.com/creating-and-viewing-access-tokens) 70 | - Set up PyPI 71 | 72 |
Using PyPI trusted publisher (modern way) 73 | 74 | - Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) 75 | - The _workflow name_ is `publish-release.yml` and the _environment_ should be left blank. 76 | - Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/)) 77 | 78 |
79 | 80 |
Using PyPI token (legacy way) 81 | 82 | - If the repo generates PyPI release(s), create a scoped PyPI [token](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github). We recommend using a scoped token for security reasons. 83 | 84 | - You can store the token as `PYPI_TOKEN` in your fork's `Secrets`. 85 | 86 | - Advanced usage: if you are releasing multiple repos, you can create a secret named `PYPI_TOKEN_MAP` instead of `PYPI_TOKEN` that is formatted as follows: 87 | 88 | ```text 89 | owner1/repo1,token1 90 | owner2/repo2,token2 91 | ``` 92 | 93 | If you have multiple Python packages in the same repository, you can point to them as follows: 94 | 95 | ```text 96 | owner1/repo1/path/to/package1,token1 97 | owner1/repo1/path/to/package2,token2 98 | ``` 99 | 100 |
101 | 102 | - Go to the Actions panel 103 | - Run the "Step 1: Prep Release" workflow 104 | - Check the draft changelog 105 | - Run the "Step 2: Publish Release" workflow 106 | 107 | ## Publishing to `conda-forge` 108 | 109 | If the package is not on conda forge yet, check the documentation to learn how to add it: https://conda-forge.org/docs/maintainer/adding_pkgs.html 110 | 111 | Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically. 112 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: '*' 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Base Setup 22 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - name: Install dependencies 25 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 26 | 27 | - name: Lint the extension 28 | run: | 29 | set -eux 30 | jlpm 31 | jlpm run lint:check 32 | 33 | #- name: Test the extension 34 | # run: | 35 | # set -eux 36 | # jlpm run test 37 | 38 | - name: Build the extension 39 | run: | 40 | set -eux 41 | python -m pip install .[test] 42 | 43 | jupyter labextension list 44 | jupyter labextension list 2>&1 | grep -ie "jupyterlab-pioneer.*OK" 45 | python -m jupyterlab.browser_check 46 | 47 | - name: Package the extension 48 | run: | 49 | set -eux 50 | 51 | pip install build 52 | python -m build 53 | pip uninstall -y "jupyterlab_pioneer" jupyterlab 54 | 55 | - name: Upload extension packages 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: extension-artifacts 59 | path: dist/jupyterlab_pioneer* 60 | if-no-files-found: error 61 | 62 | test_isolated: 63 | needs: build 64 | runs-on: ubuntu-latest 65 | 66 | steps: 67 | - name: Install Python 68 | uses: actions/setup-python@v5 69 | with: 70 | python-version: '3.9' 71 | architecture: 'x64' 72 | - uses: actions/download-artifact@v4 73 | with: 74 | name: extension-artifacts 75 | - name: Install and Test 76 | run: | 77 | set -eux 78 | # Remove NodeJS, twice to take care of system and locally installed node versions. 79 | sudo rm -rf $(which node) 80 | sudo rm -rf $(which node) 81 | 82 | pip install "jupyterlab>=4.0.0,<5" jupyterlab_pioneer*.whl 83 | 84 | 85 | jupyter labextension list 86 | jupyter labextension list 2>&1 | grep -ie "jupyterlab-pioneer.*OK" 87 | python -m jupyterlab.browser_check --no-browser-test 88 | 89 | integration-tests: 90 | name: Integration tests 91 | needs: build 92 | runs-on: ubuntu-latest 93 | 94 | env: 95 | PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/pw-browsers 96 | 97 | steps: 98 | - name: Checkout 99 | uses: actions/checkout@v4 100 | 101 | - name: Base Setup 102 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 103 | 104 | - name: Download extension package 105 | uses: actions/download-artifact@v4 106 | with: 107 | name: extension-artifacts 108 | 109 | - name: Install the extension 110 | run: | 111 | set -eux 112 | python -m pip install "jupyterlab>=4.0.0,<5" jupyterlab_pioneer*.whl 113 | 114 | #- name: Install dependencies 115 | # working-directory: ui-tests 116 | # env: 117 | # YARN_ENABLE_IMMUTABLE_INSTALLS: 0 118 | # PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 119 | # run: jlpm install 120 | 121 | #- name: Set up browser cache 122 | # uses: actions/cache@v4 123 | # with: 124 | # path: | 125 | # ${{ github.workspace }}/pw-browsers 126 | # key: ${{ runner.os }}-${{ hashFiles('ui-tests/yarn.lock') }} 127 | 128 | #- name: Install browser 129 | # run: jlpm playwright install chromium 130 | # working-directory: ui-tests 131 | 132 | #- name: Execute integration tests 133 | # working-directory: ui-tests 134 | # run: | 135 | # jlpm playwright test 136 | 137 | #- name: Upload Playwright Test report 138 | # if: always() 139 | # uses: actions/upload-artifact@v4 140 | # with: 141 | # name: jupyterlab_pioneer-playwright-tests 142 | # path: | 143 | # ui-tests/test-results 144 | # ui-tests/playwright-report 145 | 146 | check_links: 147 | name: Check Links 148 | runs-on: ubuntu-latest 149 | timeout-minutes: 15 150 | steps: 151 | - uses: actions/checkout@v4 152 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 153 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 154 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin 4 | } from '@jupyterlab/application'; 5 | import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook'; 6 | import { INotebookContent } from '@jupyterlab/nbformat'; 7 | import { IMainMenu } from '@jupyterlab/mainmenu'; 8 | import { Token } from '@lumino/coreutils'; 9 | import { requestAPI } from './handler'; 10 | import { producerCollection } from './producer'; 11 | import { ActiveEvent, Config, Exporter } from './types'; 12 | import { sendInfoNotification, addInfoToHelpMenu } from './utils'; 13 | 14 | const PLUGIN_ID = 'jupyterlab-pioneer:plugin'; 15 | 16 | export const IJupyterLabPioneer = new Token(PLUGIN_ID); 17 | 18 | export interface IJupyterLabPioneer { 19 | exporters: Exporter[]; 20 | 21 | /** 22 | * Load exporters defined in the configuration file. 23 | */ 24 | loadExporters(notebookPanel: NotebookPanel): Promise; 25 | 26 | /** 27 | * Send event data to exporters defined in the configuration file. 28 | * 29 | * @param {NotebookPanel} notebookPanel The notebook panel the extension currently listens to. 30 | * @param {object} eventDetail An object containing event details. 31 | * @param {Exporter} exporter The exporter configuration. 32 | * @param {Boolean} logWholeNotebook A boolean indicating whether to log the entire notebook or not. 33 | * @param {Boolean} logCellMetadata A boolean indicating whether to log the cell metadata or not. 34 | */ 35 | publishEvent( 36 | notebookPanel: NotebookPanel, 37 | eventDetail: object, 38 | exporter: Exporter, 39 | logWholeNotebook?: boolean 40 | ): Promise; 41 | } 42 | 43 | class JupyterLabPioneer implements IJupyterLabPioneer { 44 | exporters: Exporter[]; 45 | 46 | constructor() { 47 | this.exporters = []; 48 | } 49 | 50 | async loadExporters(notebookPanel: NotebookPanel) { 51 | const config = (await requestAPI('config')) as Config; 52 | const activeEvents: ActiveEvent[] = config.activeEvents; 53 | const exporters: Exporter[] = 54 | notebookPanel.content.model?.getMetadata('exporters') || config.exporters; // The exporters configuration in the notebook metadata overrides the configuration in the configuration file "jupyter_jupyterlab_pioneer_config.py" 55 | 56 | const processedExporters = 57 | activeEvents && activeEvents.length 58 | ? exporters.map(e => { 59 | if (!e.activeEvents) { 60 | e.activeEvents = activeEvents; 61 | return e; 62 | } else { 63 | return e; 64 | } 65 | }) 66 | : exporters.filter(e => e.activeEvents && e.activeEvents.length); 67 | // Exporters without specifying the corresponding activeEvents will use the global activeEvents configuration. 68 | // When the global activeEvents configuration is null, exporters that do not have corresponding activeEvents will be ignored. 69 | console.log(processedExporters); 70 | this.exporters = processedExporters; 71 | } 72 | 73 | async publishEvent( 74 | notebookPanel: NotebookPanel, 75 | eventDetail: object, 76 | exporter: Exporter, 77 | logWholeNotebook?: boolean 78 | ) { 79 | if (!notebookPanel) { 80 | throw Error('router is listening to a null notebook panel'); 81 | } 82 | const requestBody = { 83 | eventDetail: eventDetail, 84 | notebookState: { 85 | sessionID: notebookPanel?.sessionContext.session?.id, 86 | notebookPath: notebookPanel?.context.path, 87 | notebookContent: logWholeNotebook 88 | ? (notebookPanel?.model?.toJSON() as INotebookContent) 89 | : null // decide whether to log the entire notebook 90 | }, 91 | exporter: exporter 92 | }; 93 | const response = await requestAPI('export', { 94 | method: 'POST', 95 | body: JSON.stringify(requestBody) 96 | }); 97 | console.log(response); 98 | } 99 | } 100 | 101 | const plugin: JupyterFrontEndPlugin = { 102 | id: PLUGIN_ID, 103 | autoStart: true, 104 | requires: [INotebookTracker, IMainMenu], 105 | provides: IJupyterLabPioneer, 106 | activate: async ( 107 | app: JupyterFrontEnd, 108 | notebookTracker: INotebookTracker, 109 | mainMenu: IMainMenu 110 | ) => { 111 | const version = await requestAPI('version'); 112 | console.log(`${PLUGIN_ID}: ${version}`); 113 | const config = (await requestAPI('config')) as Config; 114 | 115 | const pioneer = new JupyterLabPioneer(); 116 | 117 | addInfoToHelpMenu(app, mainMenu, version); 118 | 119 | notebookTracker.widgetAdded.connect( 120 | async (_, notebookPanel: NotebookPanel) => { 121 | await notebookPanel.revealed; 122 | await notebookPanel.sessionContext.ready; 123 | await pioneer.loadExporters(notebookPanel); 124 | 125 | producerCollection.forEach(producer => { 126 | new producer().listen(notebookPanel, pioneer); 127 | }); 128 | } 129 | ); 130 | 131 | sendInfoNotification(config.exporters, true); 132 | 133 | return pioneer; 134 | } 135 | }; 136 | 137 | export default plugin; 138 | -------------------------------------------------------------------------------- /jupyterlab_pioneer/application.py: -------------------------------------------------------------------------------- 1 | """This module defines the extension app name, reads configurable variables from the configuration file, and adds the extra request handlers from :mod:`.handlers` module to Jupyter Server's Tornado Web Application. 2 | """ 3 | 4 | from traitlets import List, Dict 5 | from jupyter_server.extension.application import ExtensionApp 6 | from .handlers import RouteHandler 7 | 8 | class JupyterLabPioneerApp(ExtensionApp): 9 | name = "jupyterlab_pioneer" 10 | """Extension app name""" 11 | 12 | activeEvents = List([]).tag(config=True) 13 | """An array of active events defined in the configuration file. 14 | 15 | Global config, could be override by `activeEvents` defined inside of individual exporter configs. 16 | 17 | The extension would only generate and export data for valid events ( 18 | 1. that have an id associated with the event class, 19 | 2. the event name is included in `activeEvents` 20 | ). 21 | 22 | The extension will export the entire notebook content only for valid events when the `logWholeNotebook` flag is `True`. 23 | The extension will export the cell metadata only for cell related valid events when the `logCellMetadata` flag is `True`. 24 | 25 | Examples: 26 | :: 27 | 28 | # in the configuration file 29 | c.JupyterLabPioneerApp.activeEvents = 30 | [ 31 | {"name": "ActiveCellChangeEvent", "logWholeNotebook": False}, 32 | {"name": "CellAddEvent", "logWholeNotebook": False}, 33 | # {"name": "CellEditEvent", "logWholeNotebook": False}, 34 | {"name": "CellExecuteEvent", "logWholeNotebook": False}, 35 | {"name": "CellRemoveEvent", "logWholeNotebook": False}, 36 | # {"name": "ClipboardCopyEvent", "logWholeNotebook": False}, 37 | # {"name": "ClipboardCutEvent", "logWholeNotebook": False}, 38 | # {"name": "ClipboardPasteEvent", "logWholeNotebook": False}, 39 | # {"name": "NotebookHiddenEvent", "logWholeNotebook": False}, 40 | # {"name": "NotebookOpenEvent", "logWholeNotebook": False}, 41 | # {"name": "NotebookSaveEvent", "logWholeNotebook": False}, 42 | # {"name": "NotebookScrollEvent", "logWholeNotebook": False}, 43 | # {"name": "NotebookVisibleEvent", "logWholeNotebook": False}, 44 | ] 45 | """ 46 | 47 | exporters = List([]).tag(config=True) 48 | """ An array of the exporters defined in the configuration file. 49 | 50 | This extension provides 5 default exporters in the :mod:`.default_exporters` module: 51 | 52 | :func:`.default_exporters.console_exporter`, which sends telemetry data to the browser console. 53 | 54 | :func:`.default_exporters.command_line_exporter`, which sends telemetry data to the python console jupyter is running on. 55 | 56 | :func:`.default_exporters.file_exporter`, which saves telemetry data to local file. 57 | 58 | :func:`.default_exporters.remote_exporter`, which sends telemetry data to a remote http endpoint. 59 | 60 | :func:`.default_exporters.opentelemetry_exporter`, which sends telemetry data via otlp. 61 | 62 | Additionally, users can import default exporters or write customized exporters in the configuration file. 63 | 64 | Examples: 65 | :: 66 | 67 | # in the configuration file 68 | c.JupyterLabPioneerApp.exporters = 69 | [ 70 | { 71 | "type": "console_exporter", 72 | }, 73 | { 74 | "type": "command_line_exporter", 75 | }, 76 | ] 77 | """ 78 | 79 | custom_exporter = Dict({}).tag(config=True) 80 | """A dictionary of custom exporter defined in the configuration file 81 | 82 | Examples: 83 | :: 84 | 85 | # in the configuration file 86 | def my_custom_exporter(args): 87 | # write your own exporter logic here 88 | return { 89 | "exporter": args.get("id"), 90 | "message": "" 91 | } 92 | 93 | c.JupyterLabPioneerApp.exporters = [ 94 | { 95 | "type": "custom_exporter", 96 | "args": { 97 | "id": "MyCustomExporter" 98 | # add additional args for your exporter function here 99 | }, 100 | } 101 | ] 102 | 103 | c.JupyterLabPioneerApp.custom_exporter = { 104 | 'MyCustomExporter': my_custom_exporter, 105 | } 106 | """ 107 | 108 | def initialize_handlers(self): 109 | """This function adds the extra request handlers from :mod:`.handlers` module to Jupyter Server's Tornado Web Application. 110 | """ 111 | try: 112 | self.handlers.extend([(r"/jupyterlab-pioneer/(.*)", RouteHandler)]) 113 | except Exception as e: 114 | self.log.error(str(e)) 115 | raise e 116 | -------------------------------------------------------------------------------- /jupyterlab_pioneer/handlers.py: -------------------------------------------------------------------------------- 1 | """This module defines the extra request handlers the pioneer extension needs 2 | """ 3 | import os 4 | import json 5 | import inspect 6 | import tornado 7 | from jupyter_server.base.handlers import JupyterHandler 8 | from jupyter_server.extension.handler import ExtensionHandlerMixin 9 | from ._version import __version__ 10 | from .default_exporters import default_exporters 11 | 12 | class RouteHandler(ExtensionHandlerMixin, JupyterHandler): 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | 16 | # The following decorator should be present on all verb methods (head, get, post, 17 | # patch, put, delete, options) to ensure only authorized user can request the 18 | # Jupyter server 19 | @tornado.web.authenticated 20 | def get(self, resource): 21 | """GET method 22 | 23 | Args: 24 | resource (str): the name of the resource requested. It is expected to be one of "version", "environ", or "config". 25 | 26 | Returns: 27 | str(json): 28 | If resource is "version", the server responses with a json serialized obj of the version string. 29 | 30 | If resource is "environ", the server responses with a json serialized obj of current environment variables. 31 | 32 | If resource is "config", the server responses with a json serialized obj containing "activeEvents" and "exporters" configurations from the configuration file. 33 | 34 | For other resources, set the status code to 404 not found. 35 | 36 | """ 37 | try: 38 | self.set_header("Content-Type", "application/json") 39 | if resource == "version": 40 | self.finish(json.dumps(__version__)) 41 | elif resource == "environ": 42 | self.finish(json.dumps(dict(os.environ.items()))) 43 | elif resource == "config": 44 | self.finish( 45 | json.dumps( 46 | { 47 | "activeEvents": self.extensionapp.activeEvents, 48 | "exporters": self.extensionapp.exporters, 49 | } 50 | ) 51 | ) 52 | else: 53 | self.set_status(404) 54 | except Exception as e: 55 | self.log.error(str(e)) 56 | self.set_status(500) 57 | self.finish(json.dumps(str(e))) 58 | 59 | @tornado.web.authenticated 60 | async def post(self, resource): 61 | """POST method 62 | 63 | Args: 64 | resource (str): the name of the resource requested. It is expected to be "export". 65 | 66 | Returns: 67 | str(json): 68 | If resource is "export", the server calls the asynchronous export function :func:`export`, and responses with the json serialized export result. 69 | 70 | For other resources, set the status code to 404 not found. 71 | """ 72 | try: 73 | if resource == "export": 74 | result = await self.export() 75 | self.finish(json.dumps(result)) 76 | else: 77 | self.set_status(404) 78 | 79 | except Exception as e: 80 | self.log.error(str(e)) 81 | self.set_status(500) 82 | self.finish(json.dumps(str(e))) 83 | 84 | async def export(self): 85 | """This function exports telemetry data with requested exporters. 86 | 87 | The function first parse the request body to get the event data and the corresponding exporter requested for the event. 88 | Then base on the exporter type, the function either calls the default exporters or tries to access the custom exporter defined in the configuration file. 89 | 90 | Returns: 91 | dict: 92 | :: 93 | 94 | { 95 | "exporter": # exporter type, 96 | "message": # execution message of the exporter function 97 | } 98 | """ 99 | body = json.loads(self.request.body) 100 | exporter = body.get("exporter") 101 | data = { 102 | "eventDetail": body.get("eventDetail"), 103 | "notebookState": body.get("notebookState"), 104 | } 105 | exporter_type = exporter.get("type") 106 | args = exporter.get("args") or {} # id, url, path, params, env 107 | args["data"] = data 108 | if exporter_type in default_exporters: 109 | exporter_func = default_exporters[exporter_type] 110 | if inspect.iscoroutinefunction(exporter_func): 111 | result = await exporter_func(args) 112 | else: 113 | result = exporter_func(args) 114 | return result 115 | if exporter_type == "custom_exporter": 116 | custom_exporter = self.extensionapp.custom_exporter 117 | if ( 118 | custom_exporter 119 | and args.get("id") in custom_exporter 120 | and custom_exporter.get(args.get("id")) 121 | ): 122 | exporter_func = custom_exporter.get(args.get("id")) 123 | if inspect.iscoroutinefunction(exporter_func): 124 | result = await exporter_func(args) 125 | else: 126 | result = exporter_func(args) 127 | return result 128 | else: 129 | return { 130 | "exporter": exporter_type, 131 | "message": "[Error] custom exporter is not defined", 132 | } 133 | return { 134 | "exporter": exporter_type, 135 | "message": "[Error] exporter is not supported", 136 | } 137 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============== 3 | 4 | Overview 5 | ------------ 6 | 7 | The configuration file controls the activated events and data exporters. 8 | 9 | This extension provides 5 default exporters in the :mod:`.default_exporters` module: 10 | 11 | 1. :func:`.default_exporters.console_exporter`, which sends telemetry data to the browser console. 12 | 13 | 2. :func:`.default_exporters.command_line_exporter`, which sends telemetry data to the python console jupyter is running on. 14 | 15 | 3. :func:`.default_exporters.file_exporter`, which saves telemetry data to local file. 16 | 17 | 4. :func:`.default_exporters.remote_exporter`, which sends telemetry data to a remote http endpoint. 18 | 19 | 5. :func:`.default_exporters.opentelemetry_exporter`, which sends telemetry data via otlp. 20 | 21 | Default exporters will be activated if the exporter name is included by the configuration file. 22 | 23 | Additionally, users can write custom exporters in the configuration file. 24 | 25 | Configuration file name & path 26 | ------------------------------ 27 | 28 | Jupyter Server expects the configuration file to be named after the extension's name like so: `jupyter_{extension name defined in application.py}_config.py`. So, the configuration file name for this extension is ``jupyter_jupyterlab_pioneer_config.py``. 29 | 30 | Jupyter Server looks for an extension's config file in a set of specific paths. **The configuration file should be saved into one of the config directories provided by** ``jupyter --path``. 31 | 32 | Check `jupyter server documentation `_ for more details. 33 | 34 | Syntax 35 | ------ 36 | 37 | * ``activateEvents`` 38 | An array of active events. Each active event in the array should have the following structure: 39 | :: 40 | 41 | { 42 | 'name': # string, event name 43 | 'logWholeNotebook': # boolean, whether to export the entire notebook content when event is triggered 44 | } 45 | 46 | The extension would only generate and export data for valid event that: 47 | 48 | 1. has an id associated with the event class, 49 | 2. and the event name is included in ``activeEvents``. 50 | 51 | The extension will export the entire notebook content only for valid events with the ``logWholeNotebook`` flag is ``True``. 52 | 53 | Example:: 54 | 55 | c.JupyterLabPioneerApp.activeEvents = [ 56 | {"name": "ActiveCellChangeEvent", "logWholeNotebook": False}, 57 | {"name": "CellAddEvent", "logWholeNotebook": False}, 58 | {"name": "CellExecuteEvent", "logWholeNotebook": False}, 59 | {"name": "CellRemoveEvent", "logWholeNotebook": False}, 60 | ] 61 | 62 | * ``exporters`` 63 | An array of exporters. Each exporter in the array should have the following structure: 64 | :: 65 | 66 | { 67 | 'type': # One of 'console_exporter', 'command_line_exporter', 68 | # 'file_exporter', 'remote_exporter', 69 | # or 'custom_exporter'. 70 | 'args': # Optional. Arguments passed to the exporter function. 71 | # It needs to contain 'path' for file_exporter, 'url' for remote_exporter. 72 | 'activeEvents': # Optional. Exporter's local activeEvents config will override global activeEvents config 73 | } 74 | 75 | Example:: 76 | 77 | c.JupyterLabPioneerApp.exporters = [ 78 | { 79 | # sends telemetry data to the browser console 80 | "type": "console_exporter", 81 | }, 82 | { 83 | # sends telemetry data to the python console jupyter is running on 84 | "type": "command_line_exporter", 85 | }, 86 | { 87 | # writes telemetry data to local file 88 | "type": "file_exporter", 89 | "args": { 90 | "path": "log" 91 | }, 92 | }, 93 | { 94 | # sends telemetry data to a remote http endpoint (AWS S3 bucket) 95 | "type": "remote_exporter", 96 | "args": { 97 | "id": "S3Exporter", 98 | "url": "https://telemetry.mentoracademy.org/telemetry-edtech-labs-si-umich-edu/dev/test-telemetry", 99 | "env": ["WORKSPACE_ID"], 100 | }, 101 | }, 102 | ] 103 | 104 | * ``custom_exporter`` 105 | (Optional) A dictionary of custom exporter. 106 | 107 | It is accessed only when the ``exporter`` config contains an exporter with ``"type": "custom_exporter"``. If the ``exporters.args.id`` matches one of the key in the dictionary, then the corresponding custom exporter function will be called. 108 | 109 | Example:: 110 | 111 | def my_custom_exporter(args): 112 | # write your own exporter logic here 113 | return { 114 | "exporter": args.get("id"), 115 | "message": "" 116 | } 117 | 118 | c.JupyterLabPioneerApp.exporters = [ 119 | { 120 | "type": "custom_exporter", 121 | "args": { 122 | "id": "MyCustomExporter" 123 | # add additional args for your exporter function here 124 | }, 125 | } 126 | ] 127 | 128 | c.JupyterLabPioneerApp.custom_exporter = { 129 | 'MyCustomExporter': my_custom_exporter, 130 | } 131 | 132 | 133 | Complete Examples 134 | ----------------- 135 | 136 | `Default exporters`_ 137 | 138 | .. _Default exporters: https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/configuration_examples/all_exporters/jupyter_jupyterlab_pioneer_config.py 139 | 140 | `Custom exporter`_ 141 | 142 | .. _Custom exporter: https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/configuration_examples/custom_exporter/jupyter_jupyterlab_pioneer_config.py -------------------------------------------------------------------------------- /docs/custom_event.rst: -------------------------------------------------------------------------------- 1 | How to implement a custom event extension 2 | =========================================== 3 | 4 | Set up development environment 5 | ------------------------------ 6 | :: 7 | 8 | conda create -n custom_event_ext_env --override-channels --strict-channel-priority -c conda-forge -c nodefaults jupyterlab=4 nodejs=18 copier=8 jinja2-time jupyter-packaging git 9 | 10 | conda activate custom_event_ext_env 11 | 12 | 13 | Implement the extension from scratch 14 | ------------------------------------ 15 | 16 | Initialize from the extension template 17 | 18 | :: 19 | 20 | mkdir jupyterlab-pioneer-custom-event-demo 21 | 22 | cd jupyterlab-pioneer-custom-event-demo 23 | 24 | copier copy --UNSAFE https://github.com/jupyterlab/extension-template . 25 | 26 | Add ``jupyterlab-pioneer`` as a dependency in ``pyproject.toml`` and ``package.json``. 27 | 28 | .. code-block:: toml 29 | 30 | dependencies = [ 31 | "jupyter_server>=2.0.1,<3", 32 | "jupyterlab-pioneer" 33 | ] 34 | 35 | .. code-block:: 36 | 37 | "jupyterlab": { 38 | ... 39 | "sharedPackages": { 40 | "jupyterlab-pioneer": { 41 | "bundled": false, 42 | "singleton": true, 43 | "strictVersion": true 44 | } 45 | } 46 | }, 47 | 48 | Note: You will need NodeJS to build the extension package. 49 | 50 | The `jlpm` command is JupyterLab's pinned version of 51 | yarn that is installed with JupyterLab. You may use 52 | `yarn` or `npm` in lieu of `jlpm` below. 53 | :: 54 | 55 | # Install package in development mode 56 | pip install -e "." 57 | # Link your development version of the extension with JupyterLab 58 | jupyter labextension develop . --overwrite 59 | # Server extension must be manually installed in develop mode 60 | jupyter server extension enable jupyterlab-pioneer-custom-event-demo 61 | # Rebuild extension Typescript source after making changes 62 | jlpm build 63 | 64 | 65 | You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension. 66 | :: 67 | 68 | # Watch the source directory in one terminal, automatically rebuilding when needed 69 | jlpm watch 70 | # Run JupyterLab in another terminal 71 | jupyter lab 72 | 73 | 74 | Implement the extension based on the demo extension 75 | -------------------------------------------------------- 76 | 77 | :: 78 | 79 | # Clone the repo to your local environment 80 | git clone https://github.com/educational-technology-collective/jupyterlab-pioneer-custom-event-demo 81 | # Change directory to the jupyterlab-pioneer-custom-event-demo directory 82 | cd jupyterlab-pioneer-custom-event-demo 83 | # Install package in development mode 84 | pip install -e "." 85 | # Link your development version of the extension with JupyterLab 86 | jupyter labextension develop . --overwrite 87 | # Server extension must be manually installed in develop mode 88 | jupyter server extension enable jupyterlab-pioneer-custom-event-demo 89 | # Rebuild extension Typescript source after making changes 90 | jlpm build 91 | # Or watch the source directory in one terminal, automatically rebuilding when needed 92 | jlpm watch 93 | # Run JupyterLab in another terminal 94 | jupyter lab 95 | 96 | With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). 97 | 98 | By default, the `jlpm build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command:: 99 | 100 | jupyter lab build --minimize=False 101 | 102 | Development Workflow 103 | -------------------- 104 | 105 | Client Side 106 | 107 | - Make changes to the TypeScript client extension. 108 | - Refresh the browser. 109 | - Observe the changes in the running application. 110 | 111 | Server Side 112 | 113 | - Make changes to the Python server extension. 114 | - Stop the Jupyter server. 115 | - Start the Jupyter server. 116 | - Observe the changes in the running application. 117 | 118 | Useful links 119 | 120 | https://jupyterlab.readthedocs.io/en/stable/extension/extension_tutorial.html 121 | 122 | https://jupyter-server.readthedocs.io/en/latest/operators/configuring-extensions.html 123 | 124 | https://github.com/educational-technology-collective/jupyterlab-pioneer 125 | 126 | 127 | How to utilize the ``jupyter-pioneer`` extension to export telemetry data 128 | -------------------------------------------------------------------------- 129 | 130 | The ``jupyter-pioneer`` extension helps to monitor notebook states and export telemetry data. It also provides a basic JupyterLab events library. 131 | 132 | The extension's router provides the ``publishEvent`` method. 133 | 134 | ``publishEvent`` could be called whenever we want to publish the event and export telemetry data to the desired endpoints. The `publishEvent` method takes 4 arguments, `notebookPanel`, `eventDetail`, `exporter` and `logWholeNotebook`. 135 | 136 | There is generally no limitation on the structure of the `eventDetail` object, as long as the information is wrapped in a serializable javascript object. `logWholeNotebook` is optional and should be a `Boolean` object. Only if it is provided and is `true`, the router will send out the entire notebook content along with the event data. 137 | 138 | When `publishEvent` is called, the extension inserts the notebook session ID, notebook file path, and the notebook content (when `logWholeNotebook` is `true`) into the data. Then, it checks the exporter info, processes and sends out the data to the specified exporter. If `env` and `params` are provided in the configuration file when defining the desired exporter, the router would extract the environment variables and add the params to the exported data. Finally, the router will assemble the responses from the exporters in an array and print the response array in the console. 139 | 140 | **(Optional) Event Producer** 141 | 142 | There is no specific restrictions on when and where the telemetry router should be invoked. However, when writing complex event producer libraries, we recommend developers write an event producer class for each event, implement a `listen()` class method, and call the producer's `listen()` method when the producer extension is being activated. Within the `listen()` method, you may write the logic of how the extension listens to Jupyter signals or DOM events and how to use the `pioneer.publishEvent()` function to export telemetry data. 143 | 144 | **(Optional) Producer Configuration** 145 | 146 | Writing code on top of the configuration file might be very useful when the event library is complex, and when the telemetry system is going to be deployed under different contexts with different needs of telemetry events. 147 | 148 | For more details, see https://jupyter-server.readthedocs.io/en/latest/operators/configuring-extensions.html. -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: pioneer-dev 2 | channels: 3 | - conda-forge 4 | - defaults 5 | dependencies: 6 | - alabaster=0.7.13=pyhd8ed1ab_0 7 | - annotated-types=0.6.0=pyhd8ed1ab_0 8 | - anyio=4.0.0=pyhd8ed1ab_0 9 | - appnope=0.1.3=pyhd8ed1ab_0 10 | - argon2-cffi=23.1.0=pyhd8ed1ab_0 11 | - argon2-cffi-bindings=21.2.0=py312h02f2b3b_4 12 | - arrow=1.3.0=pyhd8ed1ab_0 13 | - asttokens=2.4.0=pyhd8ed1ab_0 14 | - async-lru=2.0.4=pyhd8ed1ab_0 15 | - attrs=23.1.0=pyh71513ae_1 16 | - babel=2.13.0=pyhd8ed1ab_0 17 | - backcall=0.2.0=pyh9f0ad1d_0 18 | - backports=1.0=pyhd8ed1ab_3 19 | - backports.functools_lru_cache=1.6.5=pyhd8ed1ab_0 20 | - bcrypt=4.0.1=py312h0002256_1 21 | - beautifulsoup4=4.12.2=pyha770c72_0 22 | - bleach=6.1.0=pyhd8ed1ab_0 23 | - brotli-python=1.1.0=py312h9f69965_1 24 | - bzip2=1.0.8=h3422bc3_4 25 | - c-ares=1.20.1=h93a5062_1 26 | - ca-certificates=2023.7.22=hf0a4a13_0 27 | - cached-property=1.5.2=hd8ed1ab_1 28 | - cached_property=1.5.2=pyha770c72_1 29 | - certifi=2023.7.22=pyhd8ed1ab_0 30 | - cffi=1.16.0=py312h8e38eb3_0 31 | - charset-normalizer=3.3.1=pyhd8ed1ab_0 32 | - colorama=0.4.6=pyhd8ed1ab_0 33 | - comm=0.1.4=pyhd8ed1ab_0 34 | - copier=8.3.0=pyhd8ed1ab_0 35 | - cryptography=41.0.4=py312h27708e8_0 36 | - curl=8.4.0=h2d989ff_0 37 | - debugpy=1.8.0=py312h9f69965_1 38 | - decorator=5.1.1=pyhd8ed1ab_0 39 | - defusedxml=0.7.1=pyhd8ed1ab_0 40 | - deprecation=2.1.0=pyh9f0ad1d_0 41 | - docutils=0.20.1=py312h81bd7bf_2 42 | - dunamai=1.19.0=pyhd8ed1ab_0 43 | - entrypoints=0.4=pyhd8ed1ab_0 44 | - exceptiongroup=1.1.3=pyhd8ed1ab_0 45 | - executing=1.2.0=pyhd8ed1ab_0 46 | - fqdn=1.5.1=pyhd8ed1ab_0 47 | - funcy=2.0=pyhd8ed1ab_0 48 | - gettext=0.21.1=h0186832_0 49 | - git=2.42.0=pl5321h46e2b6d_0 50 | - icu=72.1=he12128b_0 51 | - idna=3.4=pyhd8ed1ab_0 52 | - imagesize=1.4.1=pyhd8ed1ab_0 53 | - importlib-metadata=6.8.0=pyha770c72_0 54 | - importlib_metadata=6.8.0=hd8ed1ab_0 55 | - importlib_resources=6.1.0=pyhd8ed1ab_0 56 | - ipykernel=6.25.2=pyh1050b4e_0 57 | - ipython=8.16.1=pyh31c8845_0 58 | - isoduration=20.11.0=pyhd8ed1ab_0 59 | - jedi=0.19.1=pyhd8ed1ab_0 60 | - jinja2=3.1.2=pyhd8ed1ab_1 61 | - jinja2-ansible-filters=1.3.2=pyhd8ed1ab_0 62 | - jinja2-time=0.2.0=pyhd8ed1ab_3 63 | - json5=0.9.14=pyhd8ed1ab_0 64 | - jsonpointer=2.4=py312h81bd7bf_3 65 | - jsonschema=4.19.1=pyhd8ed1ab_0 66 | - jsonschema-specifications=2023.7.1=pyhd8ed1ab_0 67 | - jsonschema-with-format-nongpl=4.19.1=pyhd8ed1ab_0 68 | - jupyter-lsp=2.2.0=pyhd8ed1ab_0 69 | - jupyter-packaging=0.12.3=pyha770c72_1 70 | - jupyter_client=8.4.0=pyhd8ed1ab_0 71 | - jupyter_core=5.4.0=py312h81bd7bf_0 72 | - jupyter_events=0.8.0=pyhd8ed1ab_0 73 | - jupyter_server=2.8.0=pyhd8ed1ab_0 74 | - jupyter_server_terminals=0.4.4=pyhd8ed1ab_1 75 | - jupyterlab=4.0.7=pyhd8ed1ab_0 76 | - jupyterlab_pygments=0.2.2=pyhd8ed1ab_0 77 | - jupyterlab_server=2.25.0=pyhd8ed1ab_0 78 | - krb5=1.21.2=h92f50d5_0 79 | - libcurl=8.4.0=h2d989ff_0 80 | - libcxx=16.0.6=h4653b0c_0 81 | - libedit=3.1.20191231=hc8eb9b7_2 82 | - libev=4.33=h642e427_1 83 | - libexpat=2.5.0=hb7217d7_1 84 | - libffi=3.4.2=h3422bc3_5 85 | - libiconv=1.17=he4db4b2_0 86 | - libnghttp2=1.52.0=hae82a92_0 87 | - libsodium=1.0.18=h27ca646_1 88 | - libsqlite=3.43.2=h091b4b1_0 89 | - libssh2=1.11.0=h7a5bd25_0 90 | - libuv=1.46.0=hb547adb_0 91 | - libzlib=1.2.13=h53f4e23_5 92 | - markupsafe=2.1.3=py312h02f2b3b_1 93 | - matplotlib-inline=0.1.6=pyhd8ed1ab_0 94 | - mistune=3.0.1=pyhd8ed1ab_0 95 | - nbclient=0.8.0=pyhd8ed1ab_0 96 | - nbconvert-core=7.9.2=pyhd8ed1ab_0 97 | - nbformat=5.9.2=pyhd8ed1ab_0 98 | - ncurses=6.4=h7ea286d_0 99 | - nest-asyncio=1.5.8=pyhd8ed1ab_0 100 | - nodejs=18.17.1=ha2ed473_0 101 | - notebook-shim=0.2.3=pyhd8ed1ab_0 102 | - openssl=3.1.3=h53f4e23_0 103 | - overrides=7.4.0=pyhd8ed1ab_0 104 | - packaging=23.2=pyhd8ed1ab_0 105 | - pandocfilters=1.5.0=pyhd8ed1ab_0 106 | - paramiko=3.3.1=pyhd8ed1ab_0 107 | - parso=0.8.3=pyhd8ed1ab_0 108 | - pathspec=0.11.2=pyhd8ed1ab_0 109 | - pcre2=10.40=hb34f9b4_0 110 | - perl=5.32.1=4_hf2054a2_perl5 111 | - pexpect=4.8.0=pyh1a96a4e_2 112 | - pickleshare=0.7.5=py_1003 113 | - pip=23.3.1=pyhd8ed1ab_0 114 | - pkgutil-resolve-name=1.3.10=pyhd8ed1ab_1 115 | - platformdirs=3.11.0=pyhd8ed1ab_0 116 | - plumbum=1.8.2=pyhd8ed1ab_0 117 | - prometheus_client=0.17.1=pyhd8ed1ab_0 118 | - prompt-toolkit=3.0.39=pyha770c72_0 119 | - prompt_toolkit=3.0.39=hd8ed1ab_0 120 | - psutil=5.9.5=py312h02f2b3b_1 121 | - ptyprocess=0.7.0=pyhd3deb0d_0 122 | - pure_eval=0.2.2=pyhd8ed1ab_0 123 | - pycparser=2.21=pyhd8ed1ab_0 124 | - pydantic=2.4.2=pyhd8ed1ab_1 125 | - pydantic-core=2.10.1=py312h0002256_0 126 | - pygments=2.16.1=pyhd8ed1ab_0 127 | - pynacl=1.5.0=py312h02f2b3b_3 128 | - pyobjc-core=10.0=py312h6168cce_0 129 | - pyobjc-framework-cocoa=10.0=py312h6168cce_1 130 | - pysocks=1.7.1=pyha2e5f31_6 131 | - python=3.12.0=h47c9636_0_cpython 132 | - python-dateutil=2.8.2=pyhd8ed1ab_0 133 | - python-fastjsonschema=2.18.1=pyhd8ed1ab_0 134 | - python-json-logger=2.0.7=pyhd8ed1ab_0 135 | - python_abi=3.12=4_cp312 136 | - pytz=2023.3.post1=pyhd8ed1ab_0 137 | - pywin32-on-windows=0.1.0=pyh1179c8e_3 138 | - pyyaml=6.0.1=py312h02f2b3b_1 139 | - pyyaml-include=1.3=pyhd8ed1ab_0 140 | - pyzmq=25.1.1=py312h2105c20_2 141 | - questionary=2.0.1=pyhd8ed1ab_0 142 | - readline=8.2=h92ec313_1 143 | - referencing=0.30.2=pyhd8ed1ab_0 144 | - requests=2.31.0=pyhd8ed1ab_0 145 | - rfc3339-validator=0.1.4=pyhd8ed1ab_0 146 | - rfc3986-validator=0.1.1=pyh9f0ad1d_0 147 | - rpds-py=0.10.6=py312h5280bc4_0 148 | - send2trash=1.8.2=pyhd1c38e8_0 149 | - setuptools=68.2.2=pyhd8ed1ab_0 150 | - six=1.16.0=pyh6c4a22f_0 151 | - sniffio=1.3.0=pyhd8ed1ab_0 152 | - snowballstemmer=2.2.0=pyhd8ed1ab_0 153 | - soupsieve=2.5=pyhd8ed1ab_1 154 | - sphinx=7.2.6=pyhd8ed1ab_0 155 | - sphinxcontrib-applehelp=1.0.7=pyhd8ed1ab_0 156 | - sphinxcontrib-devhelp=1.0.5=pyhd8ed1ab_0 157 | - sphinxcontrib-htmlhelp=2.0.4=pyhd8ed1ab_0 158 | - sphinxcontrib-jsmath=1.0.1=pyhd8ed1ab_0 159 | - sphinxcontrib-qthelp=1.0.6=pyhd8ed1ab_0 160 | - sphinxcontrib-serializinghtml=1.1.9=pyhd8ed1ab_0 161 | - stack_data=0.6.2=pyhd8ed1ab_0 162 | - terminado=0.17.1=pyhd1c38e8_0 163 | - tinycss2=1.2.1=pyhd8ed1ab_0 164 | - tk=8.6.13=hb31c410_0 165 | - tomli=2.0.1=pyhd8ed1ab_0 166 | - tomlkit=0.12.1=pyha770c72_0 167 | - tornado=6.3.3=py312h02f2b3b_1 168 | - traitlets=5.11.2=pyhd8ed1ab_0 169 | - types-python-dateutil=2.8.19.14=pyhd8ed1ab_0 170 | - typing-extensions=4.8.0=hd8ed1ab_0 171 | - typing_extensions=4.8.0=pyha770c72_0 172 | - typing_utils=0.1.0=pyhd8ed1ab_0 173 | - tzdata=2023c=h71feb2d_0 174 | - uri-template=1.3.0=pyhd8ed1ab_0 175 | - urllib3=2.0.7=pyhd8ed1ab_0 176 | - wcwidth=0.2.8=pyhd8ed1ab_0 177 | - webcolors=1.13=pyhd8ed1ab_0 178 | - webencodings=0.5.1=pyhd8ed1ab_2 179 | - websocket-client=1.6.4=pyhd8ed1ab_0 180 | - wheel=0.41.2=pyhd8ed1ab_0 181 | - xz=5.2.6=h57fd34a_0 182 | - yaml=0.2.5=h3422bc3_2 183 | - zeromq=4.3.5=h965bd2d_0 184 | - zipp=3.17.0=pyhd8ed1ab_0 185 | - zlib=1.2.13=h53f4e23_5 186 | - zstd=1.5.5=h4f39d0f_0 187 | - pip: 188 | - jupyterlab-pioneer==1.0.0 189 | -------------------------------------------------------------------------------- /jupyterlab_pioneer/default_exporters.py: -------------------------------------------------------------------------------- 1 | """This module provides 5 default exporters for the extension. If the exporter function name is mentioned in the configuration file or in the notebook metadata, the extension will use the corresponding exporter function when the jupyter lab event is fired. 2 | 3 | Attributes: 4 | default_exporters: a map from function names to callable exporter functions:: 5 | 6 | default_exporters: dict[str, Callable[[dict], dict or Awaitable[dict]]] = { 7 | "console_exporter": console_exporter, 8 | "command_line_exporter": command_line_exporter, 9 | "file_exporter": file_exporter, 10 | "remote_exporter": remote_exporter, 11 | "opentelemetry_exporter": opentelemetry_exporter, 12 | } 13 | """ 14 | 15 | import json 16 | import os 17 | import datetime 18 | from collections.abc import Callable, Awaitable 19 | from tornado.httpclient import AsyncHTTPClient, HTTPRequest 20 | from tornado.httputil import HTTPHeaders 21 | from tornado.escape import to_unicode 22 | 23 | 24 | def console_exporter(args: dict) -> dict: 25 | """This exporter sends telemetry data to the browser console. 26 | 27 | Args: 28 | args(dict): arguments to pass to the exporter function, defined in the configuration file (except 'data', which is gathered by the extension). It has the following structure: 29 | :: 30 | 31 | { 32 | 'id': # (optional) exporter id, 33 | 'data': # telemetry data 34 | } 35 | 36 | Returns: 37 | dict: 38 | :: 39 | 40 | { 41 | 'exporter': # exporter id or 'ConsoleExporter', 42 | 'message': # telemetry data 43 | } 44 | 45 | """ 46 | 47 | return {"exporter": args.get("id") or "ConsoleExporter", "message": args["data"]} 48 | 49 | 50 | def command_line_exporter(args: dict) -> dict: 51 | """This exporter sends telemetry data to the python console jupyter is running on. 52 | 53 | Args: 54 | args (dict): arguments to pass to the exporter function, defined in the configuration file (except 'data', which is gathered by the extension). It has the following structure: 55 | :: 56 | 57 | { 58 | 'id': # (optional) exporter id, 59 | 'data': # telemetry data 60 | } 61 | 62 | Returns: 63 | dict: 64 | :: 65 | 66 | { 67 | 'exporter': # exporter id or 'CommandLineExporter', 68 | } 69 | 70 | """ 71 | 72 | print(args["data"]) 73 | return { 74 | "exporter": args.get("id") or "CommandLineExporter", 75 | } 76 | 77 | 78 | def file_exporter(args: dict) -> dict: 79 | """This exporter writes telemetry data to local file. 80 | 81 | Args: 82 | args (dict): arguments to pass to the exporter function, defined in the configuration file (except 'data', which is gathered by the extension). It has the following structure: 83 | :: 84 | 85 | { 86 | 'id': # (optional) exporter id, 87 | 'path': # local file path, 88 | 'data': # telemetry data 89 | } 90 | 91 | Returns: 92 | dict: 93 | :: 94 | 95 | { 96 | 'exporter': # exporter id or 'FileExporter', 97 | } 98 | """ 99 | 100 | with open(args.get("path"), "a+", encoding="utf-8") as f: 101 | json.dump(args["data"], f, ensure_ascii=False, indent=4) 102 | f.write(",") 103 | return { 104 | "exporter": args.get("id") or "FileExporter", 105 | } 106 | 107 | 108 | async def remote_exporter(args: dict) -> dict: 109 | """This exporter sends telemetry data to a remote http endpoint. 110 | 111 | Args: 112 | args (dict): arguments to pass to the exporter function, defined in the configuration file (except 'data', which is gathered by the extension). It has the following structure: 113 | :: 114 | 115 | { 116 | 'id': # (optional) exporter id, 117 | 'url': # http endpoint url, 118 | 'params': # (optional) additional parameters to pass to the http endpoint, 119 | 'env': # (optional) environment variables to pass to the http endpoint, 120 | 'data': # telemetry data 121 | } 122 | 123 | Returns: 124 | dict: 125 | :: 126 | 127 | { 128 | 'exporter': exporter id or 'RemoteExporter', 129 | 'message': { 130 | 'code': http response code, 131 | 'reason': http response reason, 132 | 'body': http response body 133 | } 134 | } 135 | 136 | """ 137 | http_client = AsyncHTTPClient() 138 | unix_timestamp = args["data"].get("eventDetail").get("eventTime") 139 | utc_datetime = datetime.datetime.fromtimestamp(unix_timestamp/1000.0, tz=datetime.timezone.utc) 140 | url = args.get("url") 141 | if "s3" in args.get("id").lower(): 142 | url = "%s/%d/%02d/%02d/%02d" % (args.get("url"), utc_datetime.year, utc_datetime.month, utc_datetime.day, utc_datetime.hour) 143 | request = HTTPRequest( 144 | url=url, 145 | method="POST", 146 | body=json.dumps( 147 | { 148 | "data": args["data"], 149 | "params": args.get( 150 | "params" 151 | ), # none if exporter does not contain 'params' 152 | "env": [{x: os.getenv(x)} for x in args.get("env")] 153 | if (args.get("env")) 154 | else [], 155 | } 156 | ), 157 | headers=HTTPHeaders({"content-type": "application/json"}), 158 | ) 159 | response = await http_client.fetch(request, raise_error=False) 160 | return { 161 | "exporter": args.get("id") or "RemoteExporter", 162 | "message": { 163 | "code": response.code, 164 | "reason": response.reason, 165 | "body": to_unicode(response.body), 166 | }, 167 | } 168 | 169 | def opentelemetry_exporter(args: dict) -> dict: 170 | """This exporter sends telemetry data via otlp 171 | 172 | """ 173 | from opentelemetry import trace 174 | 175 | current_span = trace.get_current_span() 176 | event_detail = args['data']['eventDetail'] 177 | notebook_state = args['data']['notebookState'] 178 | attributes = { 179 | "notebookSessionId": notebook_state['sessionID'], 180 | 'notebookPath': notebook_state['notebookPath'], 181 | "event": event_detail['eventName'] 182 | } 183 | current_span.add_event(event_detail['eventName'], attributes=attributes) 184 | 185 | return { 186 | "exporter": args.get("id") or "OpenTelemetryExporter", 187 | } 188 | 189 | default_exporters: "dict[str, Callable[[dict], dict or Awaitable[dict]]]" = { 190 | "console_exporter": console_exporter, 191 | "command_line_exporter": command_line_exporter, 192 | "file_exporter": file_exporter, 193 | "remote_exporter": remote_exporter, 194 | "opentelemetry_exporter": opentelemetry_exporter, 195 | } 196 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterlab-pioneer", 3 | "version": "1.7.1", 4 | "description": "A JupyterLab extension.", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/educational-technology-collective/jupyterlab-pioneer", 11 | "bugs": { 12 | "url": "https://github.com/educational-technology-collective/jupyterlab-pioneer/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "author": { 16 | "name": "Educational Technology Collective", 17 | "email": "etc-jupyterlab-telemetry@umich.edu" 18 | }, 19 | "files": [ 20 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 21 | "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}" 22 | ], 23 | "main": "lib/index.js", 24 | "types": "lib/index.d.ts", 25 | "style": "style/index.css", 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/educational-technology-collective/jupyterlab-pioneer.git" 29 | }, 30 | "scripts": { 31 | "build": "jlpm build:lib && jlpm build:labextension:dev", 32 | "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension", 33 | "build:labextension": "jupyter labextension build .", 34 | "build:labextension:dev": "jupyter labextension build --development True .", 35 | "build:lib": "tsc --sourceMap", 36 | "build:lib:prod": "tsc", 37 | "clean": "jlpm clean:lib", 38 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 39 | "clean:lintcache": "rimraf .eslintcache .stylelintcache", 40 | "clean:labextension": "rimraf jupyterlab_pioneer/labextension jupyterlab_pioneer/_version.py", 41 | "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache", 42 | "eslint": "jlpm eslint:check --fix", 43 | "eslint:check": "eslint . --cache --ext .ts,.tsx", 44 | "install:extension": "jlpm build", 45 | "lint": "jlpm stylelint && jlpm prettier && jlpm eslint", 46 | "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check", 47 | "prettier": "jlpm prettier:base --write --list-different", 48 | "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 49 | "prettier:check": "jlpm prettier:base --check", 50 | "stylelint": "jlpm stylelint:check --fix", 51 | "stylelint:check": "stylelint --cache \"style/**/*.css\"", 52 | "watch": "run-p watch:src watch:labextension", 53 | "watch:src": "tsc -w --sourceMap", 54 | "watch:labextension": "jupyter labextension watch ." 55 | }, 56 | "dependencies": { 57 | "@jupyterlab/application": "^4.0.0", 58 | "@jupyterlab/apputils": "^4.1.9", 59 | "@jupyterlab/coreutils": "^6.0.0", 60 | "@jupyterlab/mainmenu": "^4.0.9", 61 | "@jupyterlab/notebook": "^4.0.5", 62 | "@jupyterlab/services": "^7.0.0" 63 | }, 64 | "devDependencies": { 65 | "@jupyterlab/builder": "^4.0.0", 66 | "@types/json-schema": "^7.0.11", 67 | "@types/react": "^18.0.26", 68 | "@types/react-addons-linked-state-mixin": "^0.14.22", 69 | "@typescript-eslint/eslint-plugin": "^6.1.0", 70 | "@typescript-eslint/parser": "^6.1.0", 71 | "css-loader": "^6.7.1", 72 | "eslint": "^8.36.0", 73 | "eslint-config-prettier": "^8.8.0", 74 | "eslint-plugin-prettier": "^5.0.0", 75 | "mkdirp": "^1.0.3", 76 | "npm-run-all": "^4.1.5", 77 | "prettier": "^3.0.0", 78 | "rimraf": "^5.0.1", 79 | "source-map-loader": "^1.0.2", 80 | "style-loader": "^3.3.1", 81 | "stylelint": "^15.10.1", 82 | "stylelint-config-recommended": "^13.0.0", 83 | "stylelint-config-standard": "^34.0.0", 84 | "stylelint-csstree-validator": "^3.0.0", 85 | "stylelint-prettier": "^4.0.0", 86 | "typescript": "~5.0.2", 87 | "yjs": "^13.5.0" 88 | }, 89 | "sideEffects": [ 90 | "style/*.css", 91 | "style/index.js" 92 | ], 93 | "styleModule": "style/index.js", 94 | "publishConfig": { 95 | "access": "public" 96 | }, 97 | "jupyterlab": { 98 | "discovery": { 99 | "server": { 100 | "managers": [ 101 | "pip" 102 | ], 103 | "base": { 104 | "name": "jupyterlab_pioneer" 105 | } 106 | } 107 | }, 108 | "extension": true, 109 | "outputDir": "jupyterlab_pioneer/labextension" 110 | }, 111 | "eslintIgnore": [ 112 | "node_modules", 113 | "dist", 114 | "coverage", 115 | "**/*.d.ts" 116 | ], 117 | "eslintConfig": { 118 | "extends": [ 119 | "eslint:recommended", 120 | "plugin:@typescript-eslint/eslint-recommended", 121 | "plugin:@typescript-eslint/recommended", 122 | "plugin:prettier/recommended" 123 | ], 124 | "parser": "@typescript-eslint/parser", 125 | "parserOptions": { 126 | "project": "tsconfig.json", 127 | "sourceType": "module" 128 | }, 129 | "plugins": [ 130 | "@typescript-eslint" 131 | ], 132 | "rules": { 133 | "@typescript-eslint/naming-convention": [ 134 | "error", 135 | { 136 | "selector": "interface", 137 | "format": [ 138 | "PascalCase" 139 | ] 140 | } 141 | ], 142 | "@typescript-eslint/no-unused-vars": [ 143 | "warn", 144 | { 145 | "args": "none" 146 | } 147 | ], 148 | "@typescript-eslint/no-explicit-any": "off", 149 | "@typescript-eslint/no-namespace": "off", 150 | "@typescript-eslint/no-use-before-define": "off", 151 | "@typescript-eslint/quotes": [ 152 | "error", 153 | "single", 154 | { 155 | "avoidEscape": true, 156 | "allowTemplateLiterals": false 157 | } 158 | ], 159 | "curly": [ 160 | "error", 161 | "all" 162 | ], 163 | "eqeqeq": "error", 164 | "prefer-arrow-callback": "error" 165 | } 166 | }, 167 | "prettier": { 168 | "singleQuote": true, 169 | "trailingComma": "none", 170 | "arrowParens": "avoid", 171 | "endOfLine": "auto", 172 | "overrides": [ 173 | { 174 | "files": "package.json", 175 | "options": { 176 | "tabWidth": 4 177 | } 178 | } 179 | ] 180 | }, 181 | "stylelint": { 182 | "extends": [ 183 | "stylelint-config-recommended", 184 | "stylelint-config-standard", 185 | "stylelint-prettier/recommended" 186 | ], 187 | "plugins": [ 188 | "stylelint-csstree-validator" 189 | ], 190 | "rules": { 191 | "csstree/validator": true, 192 | "property-no-vendor-prefix": null, 193 | "selector-no-vendor-prefix": null, 194 | "value-no-vendor-prefix": null 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JupyterLab Pioneer 2 | 3 | 4 | 5 | [![All Contributors](https://img.shields.io/badge/all_contributors-5-orange.svg?style=flat-square)](#contributors-) 6 | 7 | 8 | 9 | [![PyPI](https://img.shields.io/pypi/v/jupyterlab-pioneer.svg)](https://pypi.org/project/jupyterlab-pioneer) 10 | [![npm](https://img.shields.io/npm/v/jupyterlab-pioneer.svg)](https://www.npmjs.com/package/jupyterlab-pioneer) 11 | 12 | A JupyterLab extension for generating and exporting JupyterLab event telemetry data. 13 | 14 | ## Get started 15 | 16 | ### Run the extension with docker compose 17 | 18 | ```bash 19 | # enter the configuration_examples directory and run 20 | docker compose -p jupyterlab_pioneer up --build 21 | ``` 22 | 23 | A JupyterLab application with the extension installed and configured will run on localhost:8888. 24 | 25 | (To play with different exporter configurations, edit [Dockerfile](https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/configuration_examples/Dockerfile#L32-L36) and run docker compose again) 26 | 27 | ### Or install the extension and configure it manually 28 | 29 | To install the extension, execute: 30 | 31 | ```bash 32 | pip install jupyterlab_pioneer 33 | ``` 34 | 35 | Before starting Jupyter Lab, users need to write their own configuration files (or use the provided configuration examples) and **place them in the correct directory**. 36 | 37 | Examples of configurations are [here](#configurations). 38 | 39 | ## Configurations 40 | 41 | ### Overview 42 | 43 | The configuration file controls the activated events and data exporters. 44 | 45 | To add a data exporter, users should assign a callable function along with function arguments when configuring `exporters`. 46 | 47 | This extension provides 5 default exporters. 48 | 49 | - [`console_exporter`](https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/jupyterlab_pioneer/default_exporters.py#L22), which sends telemetry data to the browser console 50 | - [`command_line_exporter`](https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/jupyterlab_pioneer/default_exporters.py#L48), which sends telemetry data to the python console jupyter is running on 51 | - [`file_exporter`](https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/jupyterlab_pioneer/default_exporters.py#L76), which saves telemetry data to local file 52 | - [`remote_exporter`](https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/jupyterlab_pioneer/default_exporters.py#L106), which sends telemetry data to a remote http endpoint 53 | - [`opentelemetry_exporter`](https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/jupyterlab_pioneer/default_exporters.py#L162), which sends telemetry data via otlp. 54 | 55 | Additionally, users can write customized exporters in the configuration file. 56 | 57 | ### Configuration file name & path 58 | 59 | Jupyter Server expects the configuration file to be named after the extension’s name like so: **`jupyter_{extension name defined in application.py}_config.py`**. So, the configuration file name for this extension is `jupyter_jupyterlab_pioneer_config.py`. 60 | 61 | Jupyter Server looks for an extension’s config file in a set of specific paths. **The configuration file should be saved into one of the config directories provided by `jupyter --path`.** 62 | 63 | Check jupyter server [doc](https://jupyter-server.readthedocs.io/en/latest/operators/configuring-extensions.html) for more details. 64 | 65 | ### Syntax 66 | 67 | `activateEvents`: An array of active events. Each active event in the array should have the following structure: 68 | 69 | ```python 70 | { 71 | 'name': # string, event name 72 | 'logWholeNotebook': # boolean, whether to export the entire notebook content when event is triggered 73 | 'logCellMetadata': # boolean, whether to export cell metadata when a cell related event is triggered 74 | } 75 | ``` 76 | 77 | The extension would only generate and export data for valid event that has an id associated with the event class, and the event name is included in `activeEvents`. 78 | The extension will export the entire notebook content only for valid events when the `logWholeNotebook` flag is True. 79 | It will export the cell metadata when the `logCellMetadata` flag is True. 80 | 81 | `exporters`: An array of exporters. Each exporter in the array should have the following structure: 82 | 83 | ```python 84 | { 85 | 'type': # One of 'console_exporter', 'command_line_exporter', 86 | # 'file_exporter', 'remote_exporter', 87 | # or 'custom_exporter'. 88 | 'args': # Optional. Arguments passed to the exporter function. 89 | # It needs to contain 'path' for file_exporter, 'url' for remote_exporter. 90 | 'activeEvents': # Optional. Exporter's local activeEvents config will override global activeEvents config 91 | } 92 | ``` 93 | 94 | ### Example 95 | 96 | #### Default exporters 97 | 98 | [all_exporters/jupyter_jupyterlab_pioneer_config.py](https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/configuration_examples/all_exporters/jupyter_jupyterlab_pioneer_config.py) 99 | 100 | #### Custom exporter function 101 | 102 | [custom_exporter/jupyter_jupyterlab_pioneer_config.py](https://github.com/educational-technology-collective/jupyterlab-pioneer/blob/main/configuration_examples/custom_exporter/jupyter_jupyterlab_pioneer_config.py) 103 | 104 | ## Uninstall 105 | 106 | To remove the extension, execute: 107 | 108 | ```bash 109 | pip uninstall jupyterlab_pioneer 110 | ``` 111 | 112 | ## Troubleshoot 113 | 114 | If you are seeing the frontend extension, but it is not working, check 115 | that the server extension is enabled: 116 | 117 | ```bash 118 | jupyter server extension list 119 | ``` 120 | 121 | If the server extension is installed and enabled, but you are not seeing 122 | the frontend extension, check the frontend extension is installed: 123 | 124 | ```bash 125 | jupyter labextension list 126 | ``` 127 | 128 | ## Contributing 129 | 130 | ### Development install 131 | 132 | #### (Optional) create conda environment from the provided `environment.yml` file 133 | 134 | ```bash 135 | conda env create -f environment.yml 136 | ``` 137 | 138 | #### Clone and build the extension package 139 | 140 | Note: You will need NodeJS to build the extension package. 141 | 142 | The `jlpm` command is JupyterLab's pinned version of 143 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use 144 | `yarn` or `npm` in lieu of `jlpm` below. 145 | 146 | ```bash 147 | # Clone the repo to your local environment 148 | # Change directory to the jupyterlab-pioneer directory 149 | # Install package in development mode 150 | pip install -e "." 151 | # Link your development version of the extension with JupyterLab 152 | jupyter labextension develop . --overwrite 153 | # Server extension must be manually installed in develop mode 154 | jupyter server extension enable jupyterlab_pioneer 155 | # Rebuild extension Typescript source after making changes 156 | jlpm build 157 | ``` 158 | 159 | You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension. 160 | 161 | ```bash 162 | # Watch the source directory in one terminal, automatically rebuilding when needed 163 | jlpm watch 164 | # Run JupyterLab in another terminal 165 | jupyter lab 166 | ``` 167 | 168 | With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). 169 | 170 | By default, the `jlpm build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command: 171 | 172 | ```bash 173 | jupyter lab build --minimize=False 174 | ``` 175 | 176 | ### Development uninstall 177 | 178 | ```bash 179 | # Server extension must be manually disabled in develop mode 180 | jupyter server extension disable jupyterlab_pioneer 181 | pip uninstall jupyterlab_pioneer 182 | ``` 183 | 184 | In development mode, you will also need to remove the symlink created by `jupyter labextension develop` 185 | command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions` 186 | folder is located. Then you can remove the symlink named `jupyterlab-pioneer` within that folder. 187 | 188 | ### Packaging the extension 189 | 190 | See [RELEASE](RELEASE.md) 191 | 192 | ## Contributors ✨ 193 | 194 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 |
mengyanw
Mengyan Wu

💻 🤔 🚧 📆 🚇 ⚠️ 📖
Christopher Brooks
Christopher Brooks

🤔 📆 🚇
Steve Oney
Steve Oney

🤔 📆
Christopher Ostrouchov
Christopher Ostrouchov

🤔 💻
Amit Kumar
Amit Kumar

🚇
Chiara Marmo
Chiara Marmo

💻 🚇
211 | 212 | 213 | 214 | 215 | 216 | 217 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 218 | -------------------------------------------------------------------------------- /src/producer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Notebook, 3 | NotebookPanel, 4 | NotebookActions, 5 | KernelError 6 | } from '@jupyterlab/notebook'; 7 | import { Cell, ICellModel } from '@jupyterlab/cells'; 8 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 9 | import { IObservableList } from '@jupyterlab/observables'; 10 | import { CodeMirrorEditor } from '@jupyterlab/codemirror'; 11 | import { EditorView, ViewUpdate } from '@codemirror/view'; 12 | import { IJupyterLabPioneer } from './index'; 13 | import { requestAPI } from './handler'; 14 | 15 | export class ActiveCellChangeEventProducer { 16 | static id: string = 'ActiveCellChangeEvent'; 17 | 18 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 19 | notebookPanel.content.activeCellChanged.connect( 20 | async (_, cell: Cell | null) => { 21 | if (cell && notebookPanel.content.widgets) { 22 | pioneer.exporters.forEach(async exporter => { 23 | if ( 24 | exporter.activeEvents 25 | ?.map(o => o.name) 26 | .includes(ActiveCellChangeEventProducer.id) 27 | ) { 28 | const logCellMetadata = exporter.activeEvents?.find( 29 | o => o.name === ActiveCellChangeEventProducer.id 30 | )?.logCellMetadata; 31 | const activatedCell = { 32 | id: cell?.model.id, 33 | index: notebookPanel.content.widgets.findIndex( 34 | value => value === cell 35 | ), 36 | type: cell?.model.type, 37 | metadata: logCellMetadata ? cell?.model.metadata : null 38 | }; 39 | const event = { 40 | eventName: ActiveCellChangeEventProducer.id, 41 | eventTime: Date.now(), 42 | eventInfo: { 43 | cells: [activatedCell] // activated cell 44 | } 45 | }; 46 | await pioneer.publishEvent( 47 | notebookPanel, 48 | event, 49 | exporter, 50 | exporter.activeEvents?.find( 51 | o => o.name === ActiveCellChangeEventProducer.id 52 | )?.logWholeNotebook 53 | ); 54 | } 55 | }); 56 | } 57 | } 58 | ); 59 | } 60 | } 61 | 62 | export class CellAddEventProducer { 63 | static id: string = 'CellAddEvent'; 64 | 65 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 66 | notebookPanel.content.model?.cells.changed.connect( 67 | async (_, args: IObservableList.IChangedArgs) => { 68 | if (args.type === 'add') { 69 | pioneer.exporters.forEach(async exporter => { 70 | if ( 71 | exporter.activeEvents 72 | ?.map(o => o.name) 73 | .includes(CellAddEventProducer.id) 74 | ) { 75 | const logCellMetadata = exporter.activeEvents?.find( 76 | o => o.name === CellAddEventProducer.id 77 | )?.logCellMetadata; 78 | const addedCell = { 79 | id: args.newValues[0].id, 80 | index: args.newIndex, 81 | type: args.newValues[0].type, 82 | metadata: logCellMetadata ? args.newValues[0].metadata : null 83 | }; 84 | const event = { 85 | eventName: CellAddEventProducer.id, 86 | eventTime: Date.now(), 87 | eventInfo: { 88 | cells: [addedCell] 89 | } 90 | }; 91 | await pioneer.publishEvent( 92 | notebookPanel, 93 | event, 94 | exporter, 95 | exporter.activeEvents?.find( 96 | o => o.name === CellAddEventProducer.id 97 | )?.logWholeNotebook 98 | ); 99 | } 100 | }); 101 | } 102 | } 103 | ); 104 | } 105 | } 106 | 107 | export class CellEditEventProducer { 108 | static id: string = 'CellEditEvent'; 109 | 110 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 111 | const sendDoc = async (_: Notebook, cell: Cell | null) => { 112 | await cell?.ready; // wait until cell is ready, to prevent errors when creating new cells 113 | const editor = cell?.editor as CodeMirrorEditor; 114 | 115 | pioneer.exporters.forEach(async exporter => { 116 | if ( 117 | exporter.activeEvents 118 | ?.map(o => o.name) 119 | .includes(CellEditEventProducer.id) 120 | ) { 121 | const logCellMetadata = exporter.activeEvents?.find( 122 | o => o.name === ActiveCellChangeEventProducer.id 123 | )?.logCellMetadata; 124 | const event = { 125 | eventName: CellEditEventProducer.id, 126 | eventTime: Date.now(), 127 | eventInfo: { 128 | index: notebookPanel.content.widgets.findIndex( 129 | value => value === cell 130 | ), 131 | doc: editor?.state?.doc?.toJSON(), // send entire cell content if this is a new cell, 132 | type: cell?.model.type, 133 | metadata: logCellMetadata ? cell?.model.metadata : null 134 | } 135 | }; 136 | await pioneer.publishEvent( 137 | notebookPanel, 138 | event, 139 | exporter, 140 | exporter.activeEvents?.find( 141 | o => o.name === CellEditEventProducer.id 142 | )?.logWholeNotebook 143 | ); 144 | } 145 | }); 146 | }; 147 | 148 | const addDocChangeListener = async (cell: Cell | null) => { 149 | await cell?.ready; // wait until cell is ready, to prevent errors when creating new cells 150 | const editor = cell?.editor as CodeMirrorEditor; 151 | 152 | editor?.injectExtension( 153 | EditorView.updateListener.of(async (v: ViewUpdate) => { 154 | if (v.docChanged) { 155 | const event = { 156 | eventName: CellEditEventProducer.id, 157 | eventTime: Date.now(), 158 | eventInfo: { 159 | index: notebookPanel.content.widgets.findIndex( 160 | value => value === cell 161 | ), 162 | changes: v.changes.toJSON(), // send changes 163 | type: cell?.model.type 164 | } 165 | }; 166 | pioneer.exporters.forEach(async exporter => { 167 | if ( 168 | exporter.activeEvents 169 | ?.map(o => o.name) 170 | .includes(CellEditEventProducer.id) 171 | ) { 172 | await pioneer.publishEvent( 173 | notebookPanel, 174 | event, 175 | exporter, 176 | false // do not log whole notebook for doc changes 177 | ); 178 | } 179 | }); 180 | } 181 | }) 182 | ); 183 | }; 184 | 185 | notebookPanel?.content?.widgets.forEach(cell => { 186 | addDocChangeListener(cell); 187 | }); // add listener to existing cells 188 | sendDoc(notebookPanel.content, notebookPanel.content.activeCell); // send initial active cell content 189 | 190 | notebookPanel.content.model?.cells.changed.connect( 191 | async (_, args: IObservableList.IChangedArgs) => { 192 | if (args.type === 'add') { 193 | addDocChangeListener(notebookPanel?.content?.widgets[args.newIndex]); 194 | } 195 | } 196 | ); // add doc change listener to cells created after initialization 197 | notebookPanel.content.activeCellChanged.connect(sendDoc); // send active cell content when active cell changes 198 | } 199 | } 200 | 201 | export class CellExecuteEventProducer { 202 | static id: string = 'CellExecuteEvent'; 203 | 204 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 205 | NotebookActions.executed.connect( 206 | async ( 207 | _: any, 208 | args: { 209 | notebook: Notebook; 210 | cell: Cell; 211 | success: boolean; 212 | error?: KernelError | null | undefined; 213 | } 214 | ) => { 215 | if (notebookPanel.content === args.notebook) { 216 | pioneer.exporters.forEach(async exporter => { 217 | if ( 218 | exporter.activeEvents 219 | ?.map(o => o.name) 220 | .includes(CellExecuteEventProducer.id) 221 | ) { 222 | const logCellMetadata = exporter.activeEvents?.find( 223 | o => o.name === CellExecuteEventProducer.id 224 | )?.logCellMetadata; 225 | const executedCell = { 226 | id: args.cell.model.id, 227 | index: args.notebook.widgets.findIndex( 228 | value => value === args.cell 229 | ), 230 | type: args.cell.model.type, 231 | metadata: logCellMetadata ? args.cell.model.metadata : null 232 | }; 233 | const event = { 234 | eventName: CellExecuteEventProducer.id, 235 | eventTime: Date.now(), 236 | eventInfo: { 237 | cells: [executedCell], 238 | success: args.success, 239 | kernelError: args.success ? null : args.error 240 | } 241 | }; 242 | await pioneer.publishEvent( 243 | notebookPanel, 244 | event, 245 | exporter, 246 | exporter.activeEvents?.find( 247 | o => o.name === CellExecuteEventProducer.id 248 | )?.logWholeNotebook 249 | ); 250 | } 251 | }); 252 | } 253 | } 254 | ); 255 | } 256 | } 257 | 258 | export class CellRemoveEventProducer { 259 | static id: string = 'CellRemoveEvent'; 260 | 261 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 262 | notebookPanel.content.model?.cells.changed.connect( 263 | async (_, args: IObservableList.IChangedArgs) => { 264 | if (args.type === 'remove') { 265 | pioneer.exporters.forEach(async exporter => { 266 | if ( 267 | exporter.activeEvents 268 | ?.map(o => o.name) 269 | .includes(CellRemoveEventProducer.id) 270 | ) { 271 | const logCellMetadata = exporter.activeEvents?.find( 272 | o => o.name === CellRemoveEventProducer.id 273 | )?.logCellMetadata; 274 | const removedCell = { 275 | index: args.oldIndex, 276 | type: notebookPanel.content.model?.cells.get(args.oldIndex) 277 | .type, 278 | metadata: logCellMetadata 279 | ? notebookPanel.content.activeCell?.model.metadata 280 | : null 281 | }; 282 | const event = { 283 | eventName: CellRemoveEventProducer.id, 284 | eventTime: Date.now(), 285 | eventInfo: { 286 | cells: [removedCell] 287 | } 288 | }; 289 | 290 | await pioneer.publishEvent( 291 | notebookPanel, 292 | event, 293 | exporter, 294 | exporter.activeEvents?.find( 295 | o => o.name === CellRemoveEventProducer.id 296 | )?.logWholeNotebook 297 | ); 298 | } 299 | }); 300 | } 301 | } 302 | ); 303 | } 304 | } 305 | 306 | export class ClipboardCopyEventProducer { 307 | static id: string = 'ClipboardCopyEvent'; 308 | 309 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 310 | notebookPanel.node.addEventListener('copy', async () => { 311 | pioneer.exporters.forEach(async exporter => { 312 | if ( 313 | exporter.activeEvents 314 | ?.map(o => o.name) 315 | .includes(ClipboardCopyEventProducer.id) 316 | ) { 317 | const logCellMetadata = exporter.activeEvents?.find( 318 | o => o.name === ClipboardCopyEventProducer.id 319 | )?.logCellMetadata; 320 | const cell = { 321 | id: notebookPanel.content.activeCell?.model.id, 322 | index: notebookPanel.content.widgets.findIndex( 323 | value => value === notebookPanel.content.activeCell 324 | ), 325 | type: notebookPanel.content.activeCell?.model.type, 326 | metadata: logCellMetadata 327 | ? notebookPanel.content.activeCell?.model.metadata 328 | : null 329 | }; 330 | const text = document.getSelection()?.toString(); 331 | const event = { 332 | eventName: ClipboardCopyEventProducer.id, 333 | eventTime: Date.now(), 334 | eventInfo: { 335 | cells: [cell], 336 | selection: text 337 | } 338 | }; 339 | await pioneer.publishEvent( 340 | notebookPanel, 341 | event, 342 | exporter, 343 | exporter.activeEvents?.find( 344 | o => o.name === ClipboardCopyEventProducer.id 345 | )?.logWholeNotebook 346 | ); 347 | } 348 | }); 349 | }); 350 | } 351 | } 352 | 353 | export class ClipboardCutEventProducer { 354 | static id: string = 'ClipboardCutEvent'; 355 | 356 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 357 | notebookPanel.node.addEventListener('cut', async () => { 358 | pioneer.exporters.forEach(async exporter => { 359 | if ( 360 | exporter.activeEvents 361 | ?.map(o => o.name) 362 | .includes(ClipboardCutEventProducer.id) 363 | ) { 364 | const logCellMetadata = exporter.activeEvents?.find( 365 | o => o.name === ClipboardCutEventProducer.id 366 | )?.logCellMetadata; 367 | const cell = { 368 | id: notebookPanel.content.activeCell?.model.id, 369 | index: notebookPanel.content.widgets.findIndex( 370 | value => value === notebookPanel.content.activeCell 371 | ), 372 | type: notebookPanel.content.activeCell?.model.type, 373 | metadata: logCellMetadata 374 | ? notebookPanel.content.activeCell?.model.metadata 375 | : null 376 | }; 377 | const text = document.getSelection()?.toString(); 378 | const event = { 379 | eventName: ClipboardCutEventProducer.id, 380 | eventTime: Date.now(), 381 | eventInfo: { 382 | cells: [cell], 383 | selection: text 384 | } 385 | }; 386 | await pioneer.publishEvent( 387 | notebookPanel, 388 | event, 389 | exporter, 390 | exporter.activeEvents?.find( 391 | o => o.name === ClipboardCutEventProducer.id 392 | )?.logWholeNotebook 393 | ); 394 | } 395 | }); 396 | }); 397 | } 398 | } 399 | 400 | export class ClipboardPasteEventProducer { 401 | static id: string = 'ClipboardPasteEvent'; 402 | 403 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 404 | notebookPanel.node.addEventListener('paste', async (e: ClipboardEvent) => { 405 | pioneer.exporters.forEach(async exporter => { 406 | if ( 407 | exporter.activeEvents 408 | ?.map(o => o.name) 409 | .includes(ClipboardPasteEventProducer.id) 410 | ) { 411 | const logCellMetadata = exporter.activeEvents?.find( 412 | o => o.name === ClipboardPasteEventProducer.id 413 | )?.logCellMetadata; 414 | const cell = { 415 | id: notebookPanel.content.activeCell?.model.id, 416 | index: notebookPanel.content.widgets.findIndex( 417 | value => value === notebookPanel.content.activeCell 418 | ), 419 | type: notebookPanel.content.activeCell?.model.type, 420 | metadata: logCellMetadata 421 | ? notebookPanel.content.activeCell?.model.metadata 422 | : null 423 | }; 424 | const text = ( 425 | e.clipboardData || (window as any).clipboardData 426 | ).getData('text'); 427 | const event = { 428 | eventName: ClipboardPasteEventProducer.id, 429 | eventTime: Date.now(), 430 | eventInfo: { 431 | cells: [cell], 432 | selection: text 433 | } 434 | }; 435 | await pioneer.publishEvent( 436 | notebookPanel, 437 | event, 438 | exporter, 439 | exporter.activeEvents?.find( 440 | o => o.name === ClipboardPasteEventProducer.id 441 | )?.logWholeNotebook 442 | ); 443 | } 444 | }); 445 | }); 446 | } 447 | } 448 | 449 | export class NotebookHiddenEventProducer { 450 | static id: string = 'NotebookHiddenEvent'; 451 | 452 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 453 | document.addEventListener('visibilitychange', async (e: Event) => { 454 | if ( 455 | document.visibilityState === 'hidden' && 456 | document.contains(notebookPanel.node) 457 | ) { 458 | const event = { 459 | eventName: NotebookHiddenEventProducer.id, 460 | eventTime: Date.now(), 461 | eventInfo: null 462 | }; 463 | pioneer.exporters.forEach(async exporter => { 464 | if ( 465 | exporter.activeEvents 466 | ?.map(o => o.name) 467 | .includes(NotebookHiddenEventProducer.id) 468 | ) { 469 | await pioneer.publishEvent( 470 | notebookPanel, 471 | event, 472 | exporter, 473 | exporter.activeEvents?.find( 474 | o => o.name === NotebookHiddenEventProducer.id 475 | )?.logWholeNotebook 476 | ); 477 | } 478 | }); 479 | } 480 | }); 481 | } 482 | } 483 | 484 | export class NotebookOpenEventProducer { 485 | static id: string = 'NotebookOpenEvent'; 486 | private produced: boolean = false; 487 | 488 | async listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 489 | if (!this.produced) { 490 | const event = { 491 | eventName: NotebookOpenEventProducer.id, 492 | eventTime: Date.now(), 493 | eventInfo: { 494 | environ: await requestAPI('environ') 495 | } 496 | }; 497 | pioneer.exporters.forEach(async exporter => { 498 | if ( 499 | exporter.activeEvents 500 | ?.map(o => o.name) 501 | .includes(NotebookOpenEventProducer.id) 502 | ) { 503 | await pioneer.publishEvent( 504 | notebookPanel, 505 | event, 506 | exporter, 507 | exporter.activeEvents?.find( 508 | o => o.name === NotebookOpenEventProducer.id 509 | )?.logWholeNotebook 510 | ); 511 | this.produced = true; 512 | } 513 | }); 514 | } 515 | } 516 | } 517 | 518 | export class NotebookSaveEventProducer { 519 | static id: string = 'NotebookSaveEvent'; 520 | 521 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 522 | notebookPanel.context.saveState.connect( 523 | async (_, saveState: DocumentRegistry.SaveState) => { 524 | if (saveState.match('completed')) { 525 | const event = { 526 | eventName: NotebookSaveEventProducer.id, 527 | eventTime: Date.now(), 528 | eventInfo: null 529 | }; 530 | pioneer.exporters.forEach(async exporter => { 531 | if ( 532 | exporter.activeEvents 533 | ?.map(o => o.name) 534 | .includes(NotebookSaveEventProducer.id) 535 | ) { 536 | await pioneer.publishEvent( 537 | notebookPanel, 538 | event, 539 | exporter, 540 | exporter.activeEvents?.find( 541 | o => o.name === NotebookSaveEventProducer.id 542 | )?.logWholeNotebook 543 | ); 544 | } 545 | }); 546 | } 547 | } 548 | ); 549 | } 550 | } 551 | 552 | const getVisibleCells = (notebookPanel: NotebookPanel) => { 553 | const visibleCells: Array = []; 554 | 555 | for (let index = 0; index < notebookPanel.content.widgets.length; index++) { 556 | const cell = notebookPanel.content.widgets[index]; 557 | 558 | const cellTop = cell.node.offsetTop; 559 | const cellBottom = cell.node.offsetTop + cell.node.offsetHeight; 560 | const viewTop = notebookPanel.node.getElementsByClassName( 561 | 'jp-WindowedPanel-outer' 562 | )[0].scrollTop; 563 | const viewBottom = 564 | notebookPanel.content.node.getElementsByClassName( 565 | 'jp-WindowedPanel-outer' 566 | )[0].scrollTop + 567 | notebookPanel.content.node.getElementsByClassName( 568 | 'jp-WindowedPanel-outer' 569 | )[0].clientHeight; 570 | 571 | if (cellTop <= viewBottom && cellBottom >= viewTop) { 572 | visibleCells.push({ 573 | id: cell.model.id, 574 | index: index, 575 | type: cell.model.type 576 | }); 577 | } 578 | } 579 | 580 | return visibleCells; 581 | }; 582 | 583 | export class NotebookScrollEventProducer { 584 | static id: string = 'NotebookScrollEvent'; 585 | private timeout = 0; 586 | 587 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 588 | notebookPanel.node 589 | .getElementsByClassName('jp-WindowedPanel-outer')[0] 590 | .addEventListener('scroll', async (e: Event) => { 591 | e.stopPropagation(); 592 | clearTimeout(this.timeout); 593 | await new Promise( 594 | resolve => (this.timeout = window.setTimeout(resolve, 1500)) 595 | ); // wait 1.5 seconds before preceding 596 | const event = { 597 | eventName: NotebookScrollEventProducer.id, 598 | eventTime: Date.now(), 599 | eventInfo: { 600 | cells: getVisibleCells(notebookPanel) 601 | } 602 | }; 603 | pioneer.exporters.forEach(async exporter => { 604 | if ( 605 | exporter.activeEvents 606 | ?.map(o => o.name) 607 | .includes(NotebookScrollEventProducer.id) 608 | ) { 609 | await pioneer.publishEvent( 610 | notebookPanel, 611 | event, 612 | exporter, 613 | exporter.activeEvents?.find( 614 | o => o.name === NotebookScrollEventProducer.id 615 | )?.logWholeNotebook 616 | ); 617 | } 618 | }); 619 | }); 620 | } 621 | } 622 | 623 | export class NotebookVisibleEventProducer { 624 | static id: string = 'NotebookVisibleEvent'; 625 | 626 | listen(notebookPanel: NotebookPanel, pioneer: IJupyterLabPioneer) { 627 | document.addEventListener('visibilitychange', async () => { 628 | if ( 629 | document.visibilityState === 'visible' && 630 | document.contains(notebookPanel.node) 631 | ) { 632 | const event = { 633 | eventName: NotebookVisibleEventProducer.id, 634 | eventTime: Date.now(), 635 | eventInfo: { 636 | cells: getVisibleCells(notebookPanel) 637 | } 638 | }; 639 | pioneer.exporters.forEach(async exporter => { 640 | if ( 641 | exporter.activeEvents 642 | ?.map(o => o.name) 643 | .includes(NotebookVisibleEventProducer.id) 644 | ) { 645 | await pioneer.publishEvent( 646 | notebookPanel, 647 | event, 648 | exporter, 649 | exporter.activeEvents?.find( 650 | o => o.name === NotebookVisibleEventProducer.id 651 | )?.logWholeNotebook 652 | ); 653 | } 654 | }); 655 | } 656 | }); 657 | } 658 | } 659 | 660 | export const producerCollection = [ 661 | ActiveCellChangeEventProducer, 662 | CellAddEventProducer, 663 | CellExecuteEventProducer, 664 | CellRemoveEventProducer, 665 | CellEditEventProducer, 666 | ClipboardCopyEventProducer, 667 | ClipboardCutEventProducer, 668 | ClipboardPasteEventProducer, 669 | NotebookHiddenEventProducer, 670 | NotebookOpenEventProducer, 671 | NotebookSaveEventProducer, 672 | NotebookScrollEventProducer, 673 | NotebookVisibleEventProducer 674 | ]; 675 | --------------------------------------------------------------------------------