diff --git a/package.json b/package.json index 991973cab..374d34fd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "querybook", - "version": "3.15.5", + "version": "3.15.6", "description": "A Big Data Webapp", "private": true, "scripts": { diff --git a/querybook/server/const/event_log.py b/querybook/server/const/event_log.py index 423ab8e7a..bae653dc8 100644 --- a/querybook/server/const/event_log.py +++ b/querybook/server/const/event_log.py @@ -1,12 +1,19 @@ from enum import Enum +from typing import TypedDict class EventType(Enum): # an api request - API = "api" + API = "API" # websocket event - WEBSOCKET = "websocket" + WEBSOCKET = "WEBSOCKET" # a UI element gets viewed - VIEW = "view" + VIEW = "VIEW" # a UI element gets clicked - CLICK = "click" + CLICK = "CLICK" + + +class FrontendEvent(TypedDict): + timestamp: int + event_data: dict + event_type: str # value of EventType diff --git a/querybook/server/datasources/__init__.py b/querybook/server/datasources/__init__.py index e26648e0f..3e116824e 100644 --- a/querybook/server/datasources/__init__.py +++ b/querybook/server/datasources/__init__.py @@ -13,6 +13,7 @@ from . import utils from . import table_upload from . import tag +from . import event_log # Flake8 :( admin @@ -30,3 +31,4 @@ utils table_upload tag +event_log diff --git a/querybook/server/datasources/event_log.py b/querybook/server/datasources/event_log.py new file mode 100644 index 000000000..18caf8607 --- /dev/null +++ b/querybook/server/datasources/event_log.py @@ -0,0 +1,18 @@ +from app.datasource import register +from const.event_log import EventType, FrontendEvent +from lib.event_logger import event_logger + + +@register("/event_log/", methods=["POST"], api_logging=False) +def log_frontend_event(events: list[FrontendEvent]): + """Log a list of frontend events. + + Args: + events (list[FrontendEvent]): a list of frontend events + """ + for event in events: + event_logger.log( + event_type=EventType(event["type"]), + event_data=event["data"], + timestamp=event["timestamp"], + ) diff --git a/querybook/server/lib/event_logger/__init__.py b/querybook/server/lib/event_logger/__init__.py index 45a2aec2d..d30f2dfa4 100644 --- a/querybook/server/lib/event_logger/__init__.py +++ b/querybook/server/lib/event_logger/__init__.py @@ -13,14 +13,13 @@ def __init__(self): logger_name = QuerybookSettings.EVENT_LOGGER_NAME self.logger = get_event_logger_class(logger_name) - def log( - self, - event_type: EventType, - event_data: dict, - ): + def log(self, event_type: EventType, event_data: dict, timestamp: int = None): try: self.logger.log( - uid=current_user.id, event_type=event_type, event_data=event_data + uid=current_user.id, + event_type=event_type, + event_data=event_data, + timestamp=timestamp, ) except Exception as e: # catch any potential exceptions to avoid event logging diff --git a/querybook/server/lib/event_logger/base_event_logger.py b/querybook/server/lib/event_logger/base_event_logger.py index 152052b2e..221f81c30 100644 --- a/querybook/server/lib/event_logger/base_event_logger.py +++ b/querybook/server/lib/event_logger/base_event_logger.py @@ -75,13 +75,16 @@ def _should_log_api_request(self, route: str, method: str) -> bool: return True @abstractmethod - def log(self, uid: int, event_type: EventType, event_data: dict) -> None: + def log( + self, uid: int, event_type: EventType, event_data: dict, timestamp: int = None + ) -> None: """Log an event to some data store Args: uid (int): id of the user who performed the action event_type (EventType): action event type, e.g. CLICK, VIEW event_data (dict): addtional info of the event in JSON format. + timestamp (int): timestamp in milliseconds """ raise NotImplementedError() diff --git a/querybook/server/lib/event_logger/loggers/console_event_logger.py b/querybook/server/lib/event_logger/loggers/console_event_logger.py index cac8673c1..079258189 100644 --- a/querybook/server/lib/event_logger/loggers/console_event_logger.py +++ b/querybook/server/lib/event_logger/loggers/console_event_logger.py @@ -17,8 +17,16 @@ class ConsoleEventLogger(BaseEventLogger): def logger_name(self) -> str: return "console" - def log(self, uid: int, event_type: EventType, event_data: dict): - now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + def log( + self, uid: int, event_type: EventType, event_data: dict, timestamp: int = None + ): + now = ( + datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + if timestamp is None + else datetime.utcfromtimestamp(timestamp / 1000).strftime( + "%Y-%m-%d %H:%M:%S" + ) + ) event = f"created_at: {now}, uid={uid}, event_type={event_type}, event_data={event_data}" LOG.info(f"{COLOR_YELLOW}{self.__class__.__name__} - {event}{COLOR_RESET}") diff --git a/querybook/server/lib/event_logger/loggers/db_event_logger.py b/querybook/server/lib/event_logger/loggers/db_event_logger.py index f333b18ee..e1669bc00 100644 --- a/querybook/server/lib/event_logger/loggers/db_event_logger.py +++ b/querybook/server/lib/event_logger/loggers/db_event_logger.py @@ -1,3 +1,5 @@ +from datetime import datetime + from const.event_log import EventType from lib.event_logger.base_event_logger import BaseEventLogger from models.event_log import EventLog @@ -62,7 +64,19 @@ def _api_deny_list(self) -> list: }, ] - def log(self, uid: int, event_type: EventType, event_data: dict): + def log( + self, uid: int, event_type: EventType, event_data: dict, timestamp: int = None + ): + created_at = ( + datetime.utcfromtimestamp(timestamp / 1000) + if timestamp is not None + else None + ) EventLog.create( - {"uid": uid, "event_type": event_type, "event_data": event_data} + { + "uid": uid, + "event_type": event_type, + "event_data": event_data, + "created_at": created_at, + } ) diff --git a/querybook/server/lib/event_logger/loggers/null_event_logger.py b/querybook/server/lib/event_logger/loggers/null_event_logger.py index 7b246d4f4..9983274bb 100644 --- a/querybook/server/lib/event_logger/loggers/null_event_logger.py +++ b/querybook/server/lib/event_logger/loggers/null_event_logger.py @@ -9,8 +9,7 @@ class NullEventLogger(BaseEventLogger): def logger_name(self) -> str: return "null" - def log(self, uid: int, event_type: EventType, event_data: dict) -> None: - pass - - def log_api_request(self, uid: int, route: str, method: str, params: dict) -> None: + def log( + self, uid: int, event_type: EventType, event_data: dict, timestamp: int + ) -> None: pass diff --git a/querybook/webapp/components/ChangeLog/ChangeLog.tsx b/querybook/webapp/components/ChangeLog/ChangeLog.tsx index 1f3e8f9a9..b9864b28a 100644 --- a/querybook/webapp/components/ChangeLog/ChangeLog.tsx +++ b/querybook/webapp/components/ChangeLog/ChangeLog.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; import { useParams } from 'react-router-dom'; +import { ComponentType } from 'const/analytics'; import { IChangeLogItem } from 'const/changeLog'; +import { useTrackView } from 'hooks/useTrackView'; import localStore from 'lib/local-store'; import { CHANGE_LOG_KEY, ChangeLogValue } from 'lib/local-store/const'; import { sanitizeAndExtraMarkdown } from 'lib/markdown'; @@ -29,6 +31,7 @@ const ChangeLogMarkdown: React.FC<{ markdown: string }> = ({ markdown }) => { }; export const ChangeLog: React.FunctionComponent = () => { + useTrackView(ComponentType.CHANGE_LOG); const { date: changeLogDate } = useParams(); const [changeLogContent, setChangeLogContent] = React.useState( [] diff --git a/querybook/webapp/components/EnvironmentAppSidebar/EntitySidebar.tsx b/querybook/webapp/components/EnvironmentAppSidebar/EntitySidebar.tsx index 801dc84e5..7667a4647 100644 --- a/querybook/webapp/components/EnvironmentAppSidebar/EntitySidebar.tsx +++ b/querybook/webapp/components/EnvironmentAppSidebar/EntitySidebar.tsx @@ -7,6 +7,8 @@ import { QueryEngineStatusButton } from 'components/QueryEngineStatusButton/Quer import { QueryExecutionButton } from 'components/QueryExecutionButton/QueryExecutionButton'; import { SearchContainer } from 'components/Search/SearchContainer'; import { UserMenu } from 'components/UserMenu/UserMenu'; +import { ComponentType, ElementType } from 'const/analytics'; +import { trackClick } from 'lib/analytics'; import { queryMetastoresSelector } from 'redux/dataSources/selector'; import { currentEnvironmentSelector } from 'redux/environment/selector'; import { IconButton } from 'ui/Button/IconButton'; @@ -41,6 +43,14 @@ export const EntitySidebar: React.FunctionComponent = location.pathname === `/${environment.name}/` } + onClick={() => + trackClick({ + component: + ComponentType.LEFT_SIDEBAR, + element: + ElementType.HOME_BUTTON, + }) + } /> @@ -53,6 +63,14 @@ export const EntitySidebar: React.FunctionComponent = `/${environment.name}/adhoc/` )} title="Adhoc" + onClick={() => + trackClick({ + component: + ComponentType.LEFT_SIDEBAR, + element: + ElementType.ADHOC_BUTTON, + }) + } /> = `/${environment.name}/doc_schedules/` )} title="Scheds" + onClick={() => + trackClick({ + component: + ComponentType.LEFT_SIDEBAR, + element: + ElementType.SCHEDS_BUTTON, + }) + } /> @@ -79,6 +105,10 @@ export const EntitySidebar: React.FunctionComponent = tooltipPos="right" active={selectedEntity === 'datadoc'} onClick={() => { + trackClick({ + component: ComponentType.LEFT_SIDEBAR, + element: ElementType.DOCS_BUTTON, + }); onSelectEntity('datadoc'); }} title="Docs" @@ -89,7 +119,13 @@ export const EntitySidebar: React.FunctionComponent = tooltip="Tables" tooltipPos="right" active={selectedEntity === 'table'} - onClick={() => onSelectEntity('table')} + onClick={() => { + trackClick({ + component: ComponentType.LEFT_SIDEBAR, + element: ElementType.TABLES_BUTTON, + }); + onSelectEntity('table'); + }} title="Tables" /> ) : null} @@ -98,11 +134,23 @@ export const EntitySidebar: React.FunctionComponent = tooltip="Snippets" tooltipPos="right" active={selectedEntity === 'snippet'} - onClick={() => onSelectEntity('snippet')} + onClick={() => { + trackClick({ + component: ComponentType.LEFT_SIDEBAR, + element: ElementType.SNIPS_BUTTON, + }); + onSelectEntity('snippet'); + }} title="Snips" /> onSelectEntity('execution')} + onClick={() => { + trackClick({ + component: ComponentType.LEFT_SIDEBAR, + element: ElementType.EXECS_BUTTON, + }); + onSelectEntity('execution'); + }} active={selectedEntity === 'execution'} /> diff --git a/querybook/webapp/components/InfoMenuButton/InfoMenuButton.tsx b/querybook/webapp/components/InfoMenuButton/InfoMenuButton.tsx index 4d2e14d53..23e565f69 100644 --- a/querybook/webapp/components/InfoMenuButton/InfoMenuButton.tsx +++ b/querybook/webapp/components/InfoMenuButton/InfoMenuButton.tsx @@ -1,5 +1,7 @@ import * as React from 'react'; +import { ComponentType, ElementType } from 'const/analytics'; +import { trackClick } from 'lib/analytics'; import localStore from 'lib/local-store'; import { CHANGE_LOG_KEY, ChangeLogValue } from 'lib/local-store/const'; import { navigateWithinEnv } from 'lib/utils/query-string'; @@ -105,7 +107,13 @@ export const InfoMenuButton: React.FunctionComponent = () => {
setShowPanel(true)} + onClick={() => { + trackClick({ + component: ComponentType.LEFT_SIDEBAR, + element: ElementType.HELP_BUTTON, + }); + setShowPanel(true); + }} ref={buttonRef} icon={'HelpCircle'} tooltip={'Logs, Tips, Shortcuts, & FAQs'} diff --git a/querybook/webapp/components/Landing/Landing.tsx b/querybook/webapp/components/Landing/Landing.tsx index 54429004b..cf20e8fe4 100644 --- a/querybook/webapp/components/Landing/Landing.tsx +++ b/querybook/webapp/components/Landing/Landing.tsx @@ -3,8 +3,10 @@ import React from 'react'; import { useDispatch } from 'react-redux'; import { QuerybookSidebarUIGuide } from 'components/UIGuide/QuerybookSidebarUIGuide'; +import { ComponentType } from 'const/analytics'; import { useShallowSelector } from 'hooks/redux/useShallowSelector'; import { useBrowserTitle } from 'hooks/useBrowserTitle'; +import { useTrackView } from 'hooks/useTrackView'; import { titleize } from 'lib/utils'; import { navigateWithinEnv } from 'lib/utils/query-string'; import { fetchDataDocs } from 'redux/dataDoc/action'; @@ -115,6 +117,7 @@ const DefaultLanding: React.FC = ({ children }) => { }; const Landing: React.FC = () => { + useTrackView(ComponentType.LANDING_PAGE); useBrowserTitle(); const customLandingConfig = window.CUSTOM_LANDING_PAGE; diff --git a/querybook/webapp/components/QueryEngineStatusButton/QueryEngineStatusButton.tsx b/querybook/webapp/components/QueryEngineStatusButton/QueryEngineStatusButton.tsx index b3f01f21c..ed6fc6e8b 100644 --- a/querybook/webapp/components/QueryEngineStatusButton/QueryEngineStatusButton.tsx +++ b/querybook/webapp/components/QueryEngineStatusButton/QueryEngineStatusButton.tsx @@ -9,12 +9,14 @@ import React, { import { useDispatch, useSelector } from 'react-redux'; import { QueryEngineStatusViewer } from 'components/QueryEngineStatusViewer/QueryEngineStatusViewer'; +import { ComponentType, ElementType } from 'const/analytics'; import { IQueryEngine, QueryEngineStatus } from 'const/queryEngine'; import { queryEngineStatusToIconStatus, queryEngineStatusToMessage, } from 'const/queryStatusIcon'; import { TooltipDirection } from 'const/tooltip'; +import { trackClick } from 'lib/analytics'; import { capitalize, titleize } from 'lib/utils'; import { fetchAllSystemStatus } from 'redux/queryEngine/action'; import { @@ -221,7 +223,13 @@ export const QueryEngineStatusButton: React.FC = ({ > setShowPanel(true)} + onClick={() => { + trackClick({ + component: ComponentType.LEFT_SIDEBAR, + element: ElementType.STATUS_BUTTON, + }); + setShowPanel(true); + }} ref={buttonRef} icon={'Activity'} tooltip={`Summary: ${queryEngineStatusToMessage[overallWorstQueryEngineStatus]}. Click to see details.`} diff --git a/querybook/webapp/components/Search/SearchContainer.tsx b/querybook/webapp/components/Search/SearchContainer.tsx index c3afbc58a..69cd5db8f 100644 --- a/querybook/webapp/components/Search/SearchContainer.tsx +++ b/querybook/webapp/components/Search/SearchContainer.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { ComponentType, ElementType } from 'const/analytics'; +import { trackClick } from 'lib/analytics'; import { navigateWithinEnv } from 'lib/utils/query-string'; import { IconButton } from 'ui/Button/IconButton'; @@ -7,6 +9,10 @@ import './SearchContainer.scss'; export const SearchContainer: React.FC = () => { const navigateToSearch = React.useCallback(() => { + trackClick({ + component: ComponentType.LEFT_SIDEBAR, + element: ElementType.ADHOC_BUTTON, + }); navigateWithinEnv('/search/', { isModal: true }); }, []); diff --git a/querybook/webapp/components/Search/SearchOverview.tsx b/querybook/webapp/components/Search/SearchOverview.tsx index e09970d82..28a2e22da 100644 --- a/querybook/webapp/components/Search/SearchOverview.tsx +++ b/querybook/webapp/components/Search/SearchOverview.tsx @@ -55,8 +55,8 @@ import { DataTableItem, QueryItem, } from './SearchResultItem'; -import { TableSelect } from './TableSelect'; import { SearchSchemaSelect } from './SearchSchemaSelect'; +import { TableSelect } from './TableSelect'; import './SearchOverview.scss'; @@ -359,13 +359,14 @@ export const SearchOverview: React.FC = ({ /> )) : searchType === SearchType.Table - ? (results as ITablePreview[]).map((result) => ( + ? (results as ITablePreview[]).map((result, index) => ( )) : (results as IBoardPreview[]).map((result) => ( diff --git a/querybook/webapp/components/Search/SearchResultItem.tsx b/querybook/webapp/components/Search/SearchResultItem.tsx index 875234f54..581d5d3b9 100644 --- a/querybook/webapp/components/Search/SearchResultItem.tsx +++ b/querybook/webapp/components/Search/SearchResultItem.tsx @@ -4,6 +4,7 @@ import { useSelector } from 'react-redux'; import { BoardItemAddButton } from 'components/BoardItemAddButton/BoardItemAddButton'; import { UserAvatar } from 'components/UserBadge/UserAvatar'; +import { ComponentType, ElementType } from 'const/analytics'; import { IBoardPreview, IDataDocPreview, @@ -11,6 +12,7 @@ import { ITablePreview, } from 'const/search'; import { useUser } from 'hooks/redux/useUser'; +import { trackClick } from 'lib/analytics'; import history from 'lib/router-history'; import { generateFormattedDate } from 'lib/utils/datetime'; import { stopPropagation } from 'lib/utils/noop'; @@ -325,6 +327,7 @@ interface IDataTableItemProps { searchString: string; url: string; fromBoardId: number | undefined; + pos: number; } export const DataTableItem: React.FunctionComponent = ({ @@ -332,6 +335,7 @@ export const DataTableItem: React.FunctionComponent = ({ searchString, url, fromBoardId, + pos, }) => { const selfRef = useRef(); const { @@ -343,7 +347,21 @@ export const DataTableItem: React.FunctionComponent = ({ tags, id, } = preview; - const handleClick = React.useMemo(() => openClick.bind(null, url), [url]); + const handleClick = React.useCallback( + (e) => { + trackClick({ + component: ComponentType.LEFT_SIDEBAR, + element: ElementType.TABLE_RESULT_ITEM, + aux: { + search: searchString, + table: id, + pos, + }, + }); + openClick(url, e); + }, + [url, id, pos, searchString] + ); const goldenIcon = golden ? (
diff --git a/querybook/webapp/components/UserMenu/UserMenu.tsx b/querybook/webapp/components/UserMenu/UserMenu.tsx index a11ddb75f..011e25f3e 100644 --- a/querybook/webapp/components/UserMenu/UserMenu.tsx +++ b/querybook/webapp/components/UserMenu/UserMenu.tsx @@ -3,7 +3,9 @@ import { useDispatch, useSelector } from 'react-redux'; import { TokenCreation } from 'components/Token/TokenCreation'; import { UserBadge } from 'components/UserBadge/UserBadge'; +import { ComponentType, ElementType } from 'const/analytics'; import { TooltipDirection } from 'const/tooltip'; +import { trackClick } from 'lib/analytics'; import { navigateWithinEnv } from 'lib/utils/query-string'; import { Dispatch, IStoreState } from 'redux/store/types'; import * as UserActions from 'redux/user/action'; @@ -27,10 +29,13 @@ export const UserMenu: React.FC = ({ popoverLayout = ['right', 'bottom'] as PopoverLayout, }) => { const [showUserMenuPopover, setShowUserMenuPopover] = useState(false); - const toggleUserMenuPopover = useCallback( - () => setShowUserMenuPopover((val) => !val), - [] - ); + const toggleUserMenuPopover = useCallback(() => { + trackClick({ + component: ComponentType.LEFT_SIDEBAR, + element: ElementType.SETTINGS_BUTTON, + }); + setShowUserMenuPopover((val) => !val); + }, []); const [showTokenModal, setShowTokenModal] = useState(false); const toggleShowTokenModal = useCallback( diff --git a/querybook/webapp/const/analytics.ts b/querybook/webapp/const/analytics.ts new file mode 100644 index 000000000..54d35d5a1 --- /dev/null +++ b/querybook/webapp/const/analytics.ts @@ -0,0 +1,53 @@ +// Keep it in sync with EventType in server/const/event_log.py +export enum EventType { + // API and WEBSOCKET are only used on server + API = 'API', + WEBSOCKET = 'WEBSOCKET', + + VIEW = 'VIEW', + CLICK = 'CLICK', +} + +export enum ComponentType { + LANDING_PAGE = 'LANDING_PAGE', + CHANGE_LOG = 'CHANGE_LOG', + LEFT_SIDEBAR = 'LEFT_SIDEBAR', + SEARCH_MODAL = 'SEARCH_MODAL', +} + +export enum ElementType { + // Landing page + TUTORIAL_BUTTON = 'TUTORIAL_BUTTON', + + // Side bar + HOME_BUTTON = 'HOME_BUTTON', + SEARCH_BUTTON = 'SEARCH_BUTTON', + ADHOC_BUTTON = 'ADHOC_BUTTON', + SCHEDS_BUTTON = 'SCHEDS_BUTTON', + DOCS_BUTTON = 'DOCS_BUTTON', + TABLES_BUTTON = 'TABLES_BUTTON', + SNIPS_BUTTON = 'SNIPS_BUTTON', + EXECS_BUTTON = 'EXECS_BUTTON', + STATUS_BUTTON = 'STATUS_BUTTON', + SETTINGS_BUTTON = 'SETTINGS_BUTTON', + HELP_BUTTON = 'HELP_BUTTON', + + // Search modal + TABLE_RESULT_ITEM = 'TABLE_RESULT_ITEM', + + // Data doc + RUN_QUERY_BUTTON = 'RUN_QUERY_BUTTON', +} + +export interface EventData { + path?: string; + component?: ComponentType; + element?: ElementType; + aux?: object; +} + +export interface AnalyticsEvent { + type: EventType; + data: EventData; + timestamp: number; +} diff --git a/querybook/webapp/hooks/useTrackView.ts b/querybook/webapp/hooks/useTrackView.ts new file mode 100644 index 000000000..03ae042b0 --- /dev/null +++ b/querybook/webapp/hooks/useTrackView.ts @@ -0,0 +1,10 @@ +import { useEffect } from 'react'; + +import { ComponentType } from 'const/analytics'; +import { trackView } from 'lib/analytics'; + +export function useTrackView(component: ComponentType) { + useEffect(() => { + trackView(component); + }, [component]); +} diff --git a/querybook/webapp/lib/analytics.ts b/querybook/webapp/lib/analytics.ts new file mode 100644 index 000000000..648d47d86 --- /dev/null +++ b/querybook/webapp/lib/analytics.ts @@ -0,0 +1,42 @@ +import { + AnalyticsEvent, + ComponentType, + EventData, + EventType, +} from 'const/analytics'; +import { BatchManager, mergeListFunction } from 'lib/batch/batch-manager'; +import { AnalyticsResource } from 'resource/analytics'; + +const analyticsManager = new BatchManager({ + batchFrequency: 2000, + processFunction: async (events: AnalyticsEvent[]) => { + await AnalyticsResource.create(events); + }, + mergeFunction: mergeListFunction, +}); + +const track = (eventType: EventType, eventData: EventData) => { + analyticsManager.batch({ + type: eventType, + data: eventData, + timestamp: Date.now(), + }); +}; + +export const trackView = (component?: ComponentType) => { + const eventData = { + path: location.pathname, + component, + }; + track(EventType.VIEW, eventData); +}; + +export const trackClick = ({ component, element, aux }: Partial) => { + const eventData = { + path: location.pathname, + component, + element, + aux, + }; + track(EventType.CLICK, eventData); +}; diff --git a/querybook/webapp/lib/batch/batch-manager.ts b/querybook/webapp/lib/batch/batch-manager.ts index d7a56a4ae..42ca533d8 100644 --- a/querybook/webapp/lib/batch/batch-manager.ts +++ b/querybook/webapp/lib/batch/batch-manager.ts @@ -41,6 +41,14 @@ export function mergeSetFunction(changes: Array>) { }; } +export function mergeListFunction(changes: Array>) { + return { + data: [...changes.map((c) => c.data)], + onSuccess: () => changes.forEach((c) => c.onSuccess()), + onFailure: (e: unknown) => changes.forEach((c) => c.onFailure(e)), + }; +} + export class BatchManager { private changeVersion: number = 0; private processTimeout: number = null; diff --git a/querybook/webapp/resource/analytics.ts b/querybook/webapp/resource/analytics.ts new file mode 100644 index 000000000..16bbc8857 --- /dev/null +++ b/querybook/webapp/resource/analytics.ts @@ -0,0 +1,6 @@ +import { AnalyticsEvent } from 'const/analytics'; +import ds from 'lib/datasource'; + +export const AnalyticsResource = { + create: (events: AnalyticsEvent[]) => ds.save(`/event_log/`, { events }), +};