`_) 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 | [](#contributors-)
6 |
7 |
8 |
9 | [](https://pypi.org/project/jupyterlab-pioneer)
10 | [](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 |
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 |
--------------------------------------------------------------------------------