Skip to content

Commit dc0b7ea

Browse files
KIRA009abrichr
andauthored
feat(tray): Add posthog analytics to tray actions (#737)
Co-authored-by: Richard Abrich <[email protected]>
1 parent 1338fa2 commit dc0b7ea

File tree

12 files changed

+180
-12
lines changed

12 files changed

+180
-12
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""generate_unique_user_id
2+
3+
Revision ID: bb25e889ad71
4+
Revises: a29b537fabe6
5+
Create Date: 2024-06-11 17:16:28.009900
6+
7+
"""
8+
from uuid import uuid4
9+
import json
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
from openadapt.config import config
15+
16+
# revision identifiers, used by Alembic.
17+
revision = "bb25e889ad71"
18+
down_revision = "a29b537fabe6"
19+
branch_labels = None
20+
depends_on = None
21+
22+
23+
def upgrade() -> None:
24+
# ### commands auto generated by Alembic - please adjust! ###
25+
config.UNIQUE_USER_ID = str(uuid4())
26+
# ### end Alembic commands ###
27+
28+
29+
def downgrade() -> None:
30+
# ### commands auto generated by Alembic - please adjust! ###
31+
config.UNIQUE_USER_ID = ""
32+
# ### end Alembic commands ###

openadapt/app/dashboard/api/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def attach_routes(self) -> APIRouter:
2020
self.app.add_api_route("", self.set_settings, methods=["POST"])
2121
return self.app
2222

23-
Category = Literal["api_keys", "scrubbing", "record_and_replay"]
23+
Category = Literal["api_keys", "scrubbing", "record_and_replay", "general"]
2424

2525
@staticmethod
2626
def get_settings(category: Category) -> dict[str, Any]:

openadapt/app/dashboard/app/providers.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use client'
2+
import { get } from '@/api'
23
import posthog from 'posthog-js'
34
import { PostHogProvider } from 'posthog-js/react'
5+
import { useEffect } from 'react'
46

57
if (typeof window !== 'undefined') {
68
if (process.env.NEXT_PUBLIC_MODE !== "development") {
@@ -10,8 +12,21 @@ if (typeof window !== 'undefined') {
1012
}
1113
}
1214

15+
async function getSettings(): Promise<Record<string, string>> {
16+
return get('/api/settings?category=general', {
17+
cache: 'no-store',
18+
})
19+
}
20+
1321

1422
export function CSPostHogProvider({ children }: { children: React.ReactNode }) {
23+
useEffect(() => {
24+
if (process.env.NEXT_PUBLIC_MODE !== "development") {
25+
getSettings().then((settings) => {
26+
posthog.identify(settings['UNIQUE_USER_ID'])
27+
})
28+
}
29+
}, [])
1530
if (process.env.NEXT_PUBLIC_MODE === "development") {
1631
return <>{children}</>;
1732
}

openadapt/app/tray.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from openadapt.models import Recording
4444
from openadapt.replay import replay
4545
from openadapt.strategies.base import BaseReplayStrategy
46+
from openadapt.utils import get_posthog_instance
4647
from openadapt.visualize import main as visualize
4748

4849
# ensure all strategies are registered
@@ -51,6 +52,25 @@
5152
ICON_PATH = os.path.join(FPATH, "assets", "logo.png")
5253

5354

55+
class TrackedQAction(QAction):
56+
"""QAction that tracks the recording state."""
57+
58+
def __init__(self, *args: Any, **kwargs: Any) -> None:
59+
"""Initialize the TrackedQAction.
60+
61+
Args:
62+
text (str): The text of the action.
63+
parent (QWidget): The parent widget.
64+
"""
65+
super().__init__(*args, **kwargs)
66+
self.triggered.connect(self.track_event)
67+
68+
def track_event(self) -> None:
69+
"""Track the event."""
70+
posthog = get_posthog_instance()
71+
posthog.capture(event="action_triggered", properties={"action": self.text()})
72+
73+
5474
class SystemTrayIcon:
5575
"""System tray icon for OpenAdapt."""
5676

@@ -94,7 +114,7 @@ def __init__(self) -> None:
94114

95115
self.menu = QMenu()
96116

97-
self.record_action = QAction("Record")
117+
self.record_action = TrackedQAction("Record")
98118
self.record_action.triggered.connect(self._record)
99119
self.menu.addAction(self.record_action)
100120

@@ -104,15 +124,15 @@ def __init__(self) -> None:
104124
self.populate_menus()
105125

106126
# TODO: Remove this action once dashboard is integrated
107-
# self.app_action = QAction("Show App")
127+
# self.app_action = TrackedQAction("Show App")
108128
# self.app_action.triggered.connect(self.show_app)
109129
# self.menu.addAction(self.app_action)
110130

111-
self.dashboard_action = QAction("Launch Dashboard")
131+
self.dashboard_action = TrackedQAction("Launch Dashboard")
112132
self.dashboard_action.triggered.connect(self.launch_dashboard)
113133
self.menu.addAction(self.dashboard_action)
114134

115-
self.quit = QAction("Quit")
135+
self.quit = TrackedQAction("Quit")
116136

117137
def _quit() -> None:
118138
"""Quit the application."""
@@ -424,7 +444,7 @@ def populate_menu(self, menu: QMenu, action: Callable, action_type: str) -> None
424444
self.recording_actions[action_type] = []
425445

426446
if not recordings:
427-
no_recordings_action = QAction("No recordings available")
447+
no_recordings_action = TrackedQAction("No recordings available")
428448
no_recordings_action.setEnabled(False)
429449
menu.addAction(no_recordings_action)
430450
self.recording_actions[action_type].append(no_recordings_action)
@@ -434,7 +454,7 @@ def populate_menu(self, menu: QMenu, action: Callable, action_type: str) -> None
434454
recording.timestamp
435455
).strftime("%Y-%m-%d %H:%M:%S")
436456
action_text = f"{formatted_timestamp}: {recording.task_description}"
437-
recording_action = QAction(action_text)
457+
recording_action = TrackedQAction(action_text)
438458
recording_action.triggered.connect(partial(action, recording))
439459
self.recording_actions[action_type].append(recording_action)
440460
menu.addAction(recording_action)

openadapt/config.defaults.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,6 @@
8282
"SAVE_SCREENSHOT_DIFF": false,
8383
"SPACY_MODEL_NAME": "en_core_web_trf",
8484
"DASHBOARD_CLIENT_PORT": 5173,
85-
"DASHBOARD_SERVER_PORT": 8080
85+
"DASHBOARD_SERVER_PORT": 8080,
86+
"UNIQUE_USER_ID": ""
8687
}

openadapt/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ def validate_scrub_fill_color(cls, v: Union[str, int]) -> int: # noqa: ANN102
226226

227227
SOM_SERVER_URL: str = "<SOM_SERVER_URL>"
228228

229+
UNIQUE_USER_ID: str = ""
230+
229231
class Adapter(str, Enum):
230232
"""Adapter for the completions API."""
231233

@@ -285,6 +287,9 @@ def __setattr__(self, key: str, value: Any) -> None:
285287
"RECORD_IMAGES",
286288
"VIDEO_PIXEL_FORMAT",
287289
],
290+
"general": [
291+
"UNIQUE_USER_ID",
292+
],
288293
}
289294

290295

openadapt/events.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ def get_events(
3838
Returns:
3939
list: A list of action events.
4040
"""
41+
posthog = utils.get_posthog_instance()
42+
posthog.capture("get_events.started", {"recording_id": recording.id})
4143
start_time = time.time()
4244
action_events = crud.get_action_events(db, recording)
4345
window_events = crud.get_window_events(db, recording)
@@ -46,6 +48,7 @@ def get_events(
4648
if recording.original_recording_id:
4749
# if recording is a copy, it already has its events processed when it
4850
# was created, return only the top level events
51+
posthog.capture("get_events.completed", {"recording_id": recording.id})
4952
return [event for event in action_events if event.parent_id is None]
5053

5154
raw_action_event_dicts = utils.rows2dicts(action_events)
@@ -118,6 +121,7 @@ def get_events(
118121
end_time = time.time()
119122
duration = end_time - start_time
120123
logger.info(f"{duration=}")
124+
posthog.capture("get_events.completed", {"recording_id": recording.id})
121125

122126
return action_events # , window_events, screenshots
123127

openadapt/replay.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
LOG_LEVEL = "INFO"
2525

26+
posthog = utils.get_posthog_instance()
27+
2628

2729
@logger.catch
2830
def replay(
@@ -47,6 +49,7 @@ def replay(
4749
bool: True if replay was successful, None otherwise.
4850
"""
4951
utils.configure_logging(logger, LOG_LEVEL)
52+
posthog.capture("replay.started", {"strategy_name": strategy_name})
5053

5154
if status_pipe:
5255
# TODO: move to Strategy?
@@ -99,6 +102,7 @@ def replay(
99102

100103
if status_pipe:
101104
status_pipe.send({"type": "replay.stopped"})
105+
posthog.capture("replay.stopped", {"strategy_name": strategy_name, "success": rval})
102106

103107
if record:
104108
sleep(1)

openadapt/utils.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
from jinja2 import Environment, FileSystemLoader
1919
from loguru import logger
2020
from PIL import Image, ImageEnhance
21+
from posthog import Posthog
2122

22-
from openadapt.build_utils import redirect_stdout_stderr
23+
from openadapt.build_utils import is_running_from_executable, redirect_stdout_stderr
2324

2425
with redirect_stdout_stderr():
2526
import fire
@@ -37,7 +38,12 @@
3738
mss.windows.CAPTUREBLT = 0
3839

3940

40-
from openadapt.config import PERFORMANCE_PLOTS_DIR_PATH, config
41+
from openadapt.config import (
42+
PERFORMANCE_PLOTS_DIR_PATH,
43+
POSTHOG_HOST,
44+
POSTHOG_PUBLIC_KEY,
45+
config,
46+
)
4147
from openadapt.custom_logger import filter_log_messages
4248
from openadapt.db import db
4349
from openadapt.models import ActionEvent
@@ -658,6 +664,7 @@ def trace(logger: logger) -> Any:
658664
def decorator(func: Callable) -> Callable:
659665
@wraps(func)
660666
def wrapper_logging(*args: tuple[tuple, ...], **kwargs: dict[str, Any]) -> Any:
667+
posthog = get_posthog_instance()
661668
func_name = func.__qualname__
662669
func_args = args_to_str(*args)
663670
func_kwargs = kwargs_to_str(**kwargs)
@@ -666,6 +673,14 @@ def wrapper_logging(*args: tuple[tuple, ...], **kwargs: dict[str, Any]) -> Any:
666673
logger.info(f" -> Enter: {func_name}({func_args}, {func_kwargs})")
667674
else:
668675
logger.info(f" -> Enter: {func_name}({func_args})")
676+
posthog.capture(
677+
event="function_trace",
678+
properties={
679+
"function_name": func_name,
680+
"function_args": func_args,
681+
"function_kwargs": func_kwargs,
682+
},
683+
)
669684

670685
result = func(*args, **kwargs)
671686

@@ -867,5 +882,25 @@ def split_by_separators(text: str, seps: list[str]) -> list[str]:
867882
return [part for part in parts if part]
868883

869884

885+
class DistinctIDPosthog(Posthog):
886+
"""Posthog client with a distinct ID injected into all events."""
887+
888+
def capture(self, **kwargs: Any) -> None:
889+
"""Capture an event with the distinct ID.
890+
891+
Args:
892+
**kwargs: The event properties.
893+
"""
894+
super().capture(distinct_id=config.UNIQUE_USER_ID, **kwargs)
895+
896+
897+
def get_posthog_instance() -> DistinctIDPosthog:
898+
"""Get an instance of the Posthog client."""
899+
posthog = DistinctIDPosthog(POSTHOG_PUBLIC_KEY, host=POSTHOG_HOST)
900+
if not is_running_from_executable():
901+
posthog.disabled = True
902+
return posthog
903+
904+
870905
if __name__ == "__main__":
871906
fire.Fire(get_functions(__name__))

openadapt/visualize.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@
2828
compute_diff,
2929
configure_logging,
3030
evenly_spaced,
31+
get_posthog_instance,
3132
image2utf8,
3233
row2dict,
3334
rows2dicts,
3435
)
3536

3637
SCRUB = config.SCRUB_ENABLED
38+
posthog = get_posthog_instance()
3739

3840
LOG_LEVEL = "INFO"
3941
MAX_EVENTS = None
@@ -172,6 +174,8 @@ def main(
172174

173175
assert not all([recording, recording_id]), "Only one may be specified."
174176

177+
posthog.capture("visualize.started", {"recording_id": recording_id})
178+
175179
session = crud.get_new_session(read_only=True)
176180

177181
if recording_id:
@@ -395,6 +399,7 @@ def _cleanup() -> None:
395399

396400
if cleanup:
397401
Timer(1, _cleanup).start()
402+
posthog.capture("visualize.completed", {"recording_id": recording.id})
398403
return True
399404

400405

0 commit comments

Comments
 (0)