Skip to content

Commit 0015a63

Browse files
feat: add mixpanel telemetry for session duration
1 parent 27ff087 commit 0015a63

File tree

8 files changed

+170
-23
lines changed

8 files changed

+170
-23
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ repos:
2626
hooks:
2727
- id: mypy
2828
args: [--no-strict-optional, --ignore-missing-imports]
29-
additional_dependencies: [types-requests, types-markdown, types-PyYAML]
29+
additional_dependencies: [types-requests, types-markdown, types-PyYAML, types-filelock]
3030
exclude: tests/unit/solara_test_apps/multipage/04-a_directory/*

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"reacton>=0.16.0",
1818
"ipywidgets<8",
1919
"diskcache",
20+
"filelock",
2021
"markdown",
2122
"pygments",
2223
"pymdown-extensions",
@@ -67,6 +68,7 @@ dev = [
6768
"types-markdown",
6869
"types-PyYAML",
6970
"pytest",
71+
"pytest-mock",
7072
"pytest-cov",
7173
"pytest-timeout",
7274
"pre-commit",

solara/server/settings.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1+
import os
2+
import uuid
13
from enum import Enum
4+
from pathlib import Path
25

36
import pydantic
7+
from filelock import FileLock
8+
9+
10+
def get_solara_home() -> Path:
11+
"""Get solara home directory, defaults to $HOME/.solara.
12+
13+
The $SOLARA_HOME environment variable can be set to override this default.
14+
15+
If both $SOLARA_HOME and $HOME are not define, the current working directory is used.
16+
"""
17+
if "SOLARA_HOME" in os.environ:
18+
return Path(os.environ["SOLARA_HOME"])
19+
elif "HOME" in os.environ:
20+
return Path(os.path.join(os.environ["HOME"], ".solara"))
21+
else:
22+
return Path(os.getcwd())
423

524

625
class ThemeVariant(str, Enum):
@@ -20,6 +39,19 @@ class Config:
2039
env_file = ".env"
2140

2241

42+
class Telemetry(pydantic.BaseSettings):
43+
mixpanel_token: str = "91845eb13a68e3db4e58d64ad23673b7"
44+
mixpanel_enable: bool = True
45+
server_user_id: str = "not_set"
46+
server_fingerprint: str = str(uuid.getnode())
47+
server_session_id: str = str(uuid.uuid4())
48+
49+
class Config:
50+
env_prefix = "solara_telemetry_"
51+
case_sensitive = False
52+
env_file = ".env"
53+
54+
2355
class MainSettings(pydantic.BaseSettings):
2456
use_pdb: bool = False
2557
mode: str = "production"
@@ -34,3 +66,16 @@ class Config:
3466

3567
main = MainSettings()
3668
theme = ThemeSettings()
69+
telemetry = Telemetry()
70+
71+
72+
home = get_solara_home()
73+
if not home.exists():
74+
home.mkdir(parents=True, exist_ok=True)
75+
76+
if telemetry.server_user_id == "not_set":
77+
server_user_id_file = home / "server_user_id.txt"
78+
with FileLock(str(server_user_id_file) + ".lock"):
79+
if not server_user_id_file.exists():
80+
server_user_id_file.write_text(str(uuid.uuid4()))
81+
telemetry.server_user_id = server_user_id_file.read_text()

solara/server/starlette.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from solara.server import reload
1818

1919
from . import app as appmod
20-
from . import patch, server, websocket
20+
from . import patch, server, telemetry, websocket
2121
from .cdn_helper import cdn_url_path, default_cache_dir, get_path
2222

2323
os.environ["SERVER_SOFTWARE"] = "solara/" + str(solara.__version__)
@@ -192,6 +192,11 @@ def on_startup():
192192
# TODO: configure and set max number of threads
193193
# see https://github.com/encode/starlette/issues/1724
194194
reload.reloader.start()
195+
telemetry.server_start()
196+
197+
198+
def on_shutdown():
199+
telemetry.server_stop()
195200

196201

197202
routes = [
@@ -211,5 +216,6 @@ def on_startup():
211216
app = Starlette(
212217
routes=routes,
213218
on_startup=[on_startup],
219+
on_shutdown=[on_shutdown],
214220
)
215221
patch.patch()

solara/server/telemetry.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import json
2+
import logging
3+
import time
4+
import uuid
5+
from typing import Dict, Optional
6+
from urllib.parse import quote
7+
8+
import ipyvue
9+
import ipyvuetify
10+
import ipywidgets
11+
import requests
12+
13+
import solara
14+
15+
from . import settings
16+
17+
logger = logging.getLogger("solara.server.telemetry")
18+
19+
_server_user_id_override = None
20+
_server_start_time = time.time()
21+
22+
23+
solara_props = {
24+
"solara_version": solara.__version__,
25+
"ipywidgets_version": ipywidgets.__version__,
26+
"ipyvuetify_version": ipyvuetify.__version__,
27+
"ipyvue_version": ipyvue.__version__,
28+
}
29+
30+
31+
def override_server_user_id(server_user_id: str):
32+
global _server_user_id_override
33+
_server_user_id_override = server_user_id
34+
35+
36+
def get_server_user_id():
37+
return _server_user_id_override or settings.telemetry.server_user_id
38+
39+
40+
def track(event: str, props: Optional[Dict] = None):
41+
if not settings.telemetry.mixpanel_enable:
42+
return
43+
event_item = {
44+
"event": event,
45+
"properties": {
46+
"token": settings.telemetry.mixpanel_token,
47+
"fingerprint": settings.telemetry.server_fingerprint,
48+
"time": int(time.time() * 1000),
49+
"distinct_id": get_server_user_id(),
50+
# can be useful to get of session duration
51+
"session_id": settings.telemetry.server_session_id,
52+
"$insert_id": str(uuid.uuid4()), # to de-duplicate events
53+
**(solara_props or {}),
54+
**(props or {}),
55+
},
56+
}
57+
try:
58+
requests.post(
59+
"https://api.mixpanel.com/track/",
60+
headers={"content-type": "application/x-www-form-urlencoded"},
61+
data=f"data={quote(json.dumps([event_item]))}",
62+
timeout=1,
63+
)
64+
except Exception:
65+
logger.exception("Failed mixpanel API call")
66+
67+
68+
def server_start():
69+
global _server_start_time
70+
_server_start_time = time.time()
71+
track("Solara server start")
72+
73+
74+
def server_stop():
75+
duration = time.time() - _server_start_time
76+
track("Solara server stop", {"duration_seconds": duration})
77+
78+
79+
if __name__ == "__main__":
80+
track("Solara test event", {"where": "command line"})
Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,11 @@
1-
# Scopes
1+
# Solara server
22

3-
## Application scope
3+
The solara server enables running ipywidgets based applications without a real Jupyter kernel, allowing multiple "Virtual kernels" to share the same process for better performance and scalability.
44

5-
Does not exist (yet), although equals process scope when using a single worker. Could be implemented using Redis.
65

7-
## Worker scope
6+
## Telemetry
87

9-
The scope of a single worker. E.g. all Python imported modules live in this scope, so Solara does not explicitly support this. Your application (when using React elements) will also live in this scope.
8+
Solara uses Mixpanel to collect usage of the solara server. We track when a server is started and stopped. To opt out of mixpanel telemetry, either:
109

11-
```python
12-
import solara as sol
13-
# only load a global dataframe once per worker
14-
if "df" not in solara.scope.worker:
15-
process_scope["df"] = ....
16-
```
17-
18-
## User scope
19-
20-
Things like shopping carts should go here.
21-
22-
## UI scope
23-
24-
Connected to the life-time of a single browser tab.
25-
26-
## React scope
10+
* Set the environmental variable `SOLARA_TELEMETRY_MIXPANEL_ENABLE` to `False`.
11+
* Install [python-dotenv](https://pypi.org/project/python-dotenv/) and put `SOLARA_TELEMETRY_MIXPANEL_ENABLE=False` in a `.env` file.

tests/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33

44
import pytest
55

6+
import solara.server.settings
67
from solara.server import kernel, patch
78
from solara.server.app import AppContext
89

10+
solara.server.settings.telemetry.mixpanel_token = "adbf863d17cba80db608788e7fce9843"
11+
912

1013
@pytest.fixture(scope="session")
1114
def solara_patched():

tests/unit/telemetry_tests.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import json
2+
import unittest.mock
3+
from urllib.parse import unquote
4+
5+
import requests
6+
7+
import solara.server.telemetry
8+
9+
10+
def test_telemetry_basic(mocker):
11+
12+
post: unittest.mock.MagicMock = mocker.spy(requests, "post")
13+
solara.server.telemetry.track("test_event", {"test_prop": "test_value"})
14+
post.assert_called_once()
15+
data = json.loads(unquote(post.call_args[1]["data"])[5:])[0] # type: ignore
16+
assert data["event"] == "test_event"
17+
18+
19+
def test_telemetry_server_start_stopc(mocker):
20+
post: unittest.mock.MagicMock = mocker.spy(requests, "post")
21+
solara.server.telemetry.server_start()
22+
solara.server.telemetry.server_stop()
23+
post.assert_called()
24+
data = json.loads(unquote(post.call_args[1]["data"])[5:])[0] # type: ignore
25+
assert data["event"] == "Solara server stop"
26+
assert data["properties"]["duration_seconds"] > 0

0 commit comments

Comments
 (0)