Add hotkey dialog and rust-only analytics

This commit is contained in:
Gregory Schier
2024-01-10 16:18:08 -08:00
parent 88d024023b
commit 7dc78a1f6f
23 changed files with 152 additions and 144 deletions

View File

@@ -1,89 +1,109 @@
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use sqlx::types::JsonValue;
use tauri::{async_runtime, AppHandle, Manager};
use crate::is_dev;
// serializable
#[derive(Serialize, Deserialize)]
pub enum AnalyticsResource {
App,
// Workspace,
// Environment,
// Folder,
// HttpRequest,
// HttpResponse,
Workspace,
Environment,
Folder,
HttpRequest,
HttpResponse,
}
#[derive(Serialize, Deserialize)]
pub enum AnalyticsAction {
Launch,
// Create,
// Update,
// Upsert,
// Delete,
// Send,
// Duplicate,
Create,
Update,
Upsert,
Delete,
DeleteMany,
Send,
Duplicate,
}
fn resource_name(resource: AnalyticsResource) -> &'static str {
match resource {
AnalyticsResource::App => "app",
// AnalyticsResource::Workspace => "workspace",
// AnalyticsResource::Environment => "environment",
// AnalyticsResource::Folder => "folder",
// AnalyticsResource::HttpRequest => "http_request",
// AnalyticsResource::HttpResponse => "http_response",
AnalyticsResource::Workspace => "workspace",
AnalyticsResource::Environment => "environment",
AnalyticsResource::Folder => "folder",
AnalyticsResource::HttpRequest => "http_request",
AnalyticsResource::HttpResponse => "http_response",
}
}
fn action_name(action: AnalyticsAction) -> &'static str {
match action {
AnalyticsAction::Launch => "launch",
// AnalyticsAction::Create => "create",
// AnalyticsAction::Update => "update",
// AnalyticsAction::Upsert => "upsert",
// AnalyticsAction::Delete => "delete",
// AnalyticsAction::Send => "send",
// AnalyticsAction::Duplicate => "duplicate",
AnalyticsAction::Create => "create",
AnalyticsAction::Update => "update",
AnalyticsAction::Upsert => "upsert",
AnalyticsAction::Delete => "delete",
AnalyticsAction::DeleteMany => "delete_many",
AnalyticsAction::Send => "send",
AnalyticsAction::Duplicate => "duplicate",
}
}
pub fn track_event(
pub fn track_event_blocking(
app_handle: &AppHandle,
resource: AnalyticsResource,
action: AnalyticsAction,
attributes: Option<JsonValue>,
) {
async_runtime::block_on(async move {
let event = format!("{}.{}", resource_name(resource), action_name(action));
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
let info = app_handle.package_info();
let tz = datetime::sys_timezone().unwrap_or("unknown".to_string());
let params = vec![
("e", event.clone()),
("a", attributes_json.clone()),
("id", "site_zOK0d7jeBy2TLxFCnZ".to_string()),
("v", info.version.clone().to_string()),
("os", get_os().to_string()),
("tz", tz),
("xy", get_window_size(app_handle)),
];
let url = "https://t.yaak.app/t/e".to_string();
let req = reqwest::Client::builder()
.build()
.unwrap()
.get(&url)
.query(&params);
track_event(app_handle, resource, action, attributes).await;
});
}
if is_dev() {
debug!("Send event (dev): {}", event);
} else if let Err(e) = req.send().await {
warn!(
pub async fn track_event(
app_handle: &AppHandle,
resource: AnalyticsResource,
action: AnalyticsAction,
attributes: Option<JsonValue>,
) {
let event = format!("{}.{}", resource_name(resource), action_name(action));
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
let info = app_handle.package_info();
let tz = datetime::sys_timezone().unwrap_or("unknown".to_string());
let site = match is_dev() {
true => "site_TkHWjoXwZPq3HfhERb",
false => "site_zOK0d7jeBy2TLxFCnZ",
};
let base_url = match is_dev() {
true => "http://localhost:7194",
false => "https://t.yaak.app"
};
let params = vec![
("e", event.clone()),
("a", attributes_json.clone()),
("id", site.to_string()),
("v", info.version.clone().to_string()),
("os", get_os().to_string()),
("tz", tz),
("xy", get_window_size(app_handle)),
];
let req = reqwest::Client::builder()
.build()
.unwrap()
.get(format!("{base_url}/t/e"))
.query(&params);
if let Err(e) = req.send().await {
warn!(
"Error sending analytics event: {} {} {:?}",
e, event, params
);
} else {
debug!("Send event: {}: {:?}", event, params);
}
});
} else {
debug!("Send event: {}: {:?}", event, params);
}
}
fn get_os() -> &'static str {

View File

@@ -16,6 +16,7 @@ use fern::colors::ColoredLevelConfig;
use log::{debug, info, warn};
use rand::random;
use serde::Serialize;
use serde_json::Value;
use sqlx::{Pool, Sqlite, SqlitePool};
use sqlx::migrate::Migrator;
use sqlx::types::Json;
@@ -29,7 +30,7 @@ use tokio::sync::Mutex;
use window_ext::TrafficLightWindowExt;
use crate::analytics::{AnalyticsAction, AnalyticsResource, track_event};
use crate::analytics::{AnalyticsAction, AnalyticsResource};
use crate::plugin::{ImportResources, ImportResult};
use crate::send::actually_send_request;
use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
@@ -224,6 +225,22 @@ async fn response_err(
Ok(response)
}
#[tauri::command]
async fn track_event(
window: Window<Wry>,
resource: AnalyticsResource,
action: AnalyticsAction,
attributes: Option<Value>,
) -> Result<(), String> {
analytics::track_event(
&window.app_handle(),
resource,
action,
attributes,
).await;
Ok(())
}
#[tauri::command]
async fn set_update_mode(
update_mode: &str,
@@ -726,6 +743,7 @@ fn main() {
send_request,
set_key_value,
set_update_mode,
track_event,
update_environment,
update_folder,
update_request,
@@ -762,7 +780,7 @@ fn main() {
w.restore_state(StateFlags::all())
.expect("Failed to restore window state");
track_event(
analytics::track_event_blocking(
app_handle,
AnalyticsResource::App,
AnalyticsAction::Launch,

View File

@@ -35,8 +35,11 @@ pub async fn actually_send_request(
}
let client = reqwest::Client::builder()
.redirect(Policy::none())
// .danger_accept_invalid_certs(true)
.redirect(Policy::none()) // TODO: Handle redirect manually
.danger_accept_invalid_certs(false) // TODO: Make this configurable
.connection_verbose(true) // TODO: Capture this log somehow
.tls_info(true) // TODO: Capture this log somehow
// .use_rustls_tls() // TODO: Make this configurable
.build()
.expect("Failed to build client");
@@ -237,6 +240,9 @@ pub async fn actually_send_request(
}
Ok(response)
}
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
Err(e) => {
println!("Yo: {}", e);
response_err(response, e.to_string(), app_handle, pool).await
},
}
}

View File

@@ -2,7 +2,6 @@ import { useQueryClient } from '@tanstack/react-query';
import { appWindow } from '@tauri-apps/api/window';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
@@ -13,7 +12,6 @@ import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { responsesQueryKey } from '../hooks/useResponses';
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { trackPage } from '../lib/analytics';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
@@ -39,10 +37,6 @@ export function GlobalHooks() {
setPathname(location.pathname).catch(console.error);
}, [location.pathname]);
useEffectOnce(() => {
trackPage('/');
});
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;

View File

@@ -0,0 +1,10 @@
import { hotkeyActions } from '../hooks/useHotkey';
import { HotKeyList } from './core/HotKeyList';
export const KeyboardShortcutsDialog = () => {
return (
<div className="h-full w-full">
<HotKeyList hotkeys={hotkeyActions} />
</div>
);
};

View File

@@ -12,6 +12,7 @@ import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
export function SettingsDropdown() {
const importData = useImportData();
@@ -66,7 +67,18 @@ export function SettingsDropdown() {
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
{ type: 'separator', label: `v${appVersion.data}` },
{
key: 'hotkeys',
label: 'Keyboard shortcuts',
onSelect: () =>
dialog.show({
title: 'Keyboard Shortcuts',
size: 'dynamic',
render: () => <KeyboardShortcutsDialog />,
}),
leftSlot: <Icon icon="keyboard" />,
},
{ type: 'separator', label: `Yaak v${appVersion.data}` },
{
key: 'update-mode',
label: updateMode === 'stable' ? 'Enable Beta' : 'Disable Beta',

View File

@@ -290,27 +290,13 @@ function getExtensions({
// Handle onChange
EditorView.updateListener.of((update) => {
if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) {
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
}),
];
}
function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) {
// Make sure document has changed, ensuring user events like selections don't count.
if (viewUpdate.docChanged) {
// Check transactions for any that are direct user input, not changes from Y.js or another extension.
for (const transaction of viewUpdate.transactions) {
// Not using Transaction.isUserEvent because that only checks for a specific User event type ( "input", "delete", etc.). Checking the annotation directly allows for any type of user event.
const userEventType = transaction.annotation(Transaction.userEvent);
if (userEventType) return userEventType;
}
}
return false;
}
const syncGutterBg = ({
parent,
className = '',

View File

@@ -26,6 +26,7 @@ const icons = {
gear: I.GearIcon,
hamburger: I.HamburgerMenuIcon,
home: I.HomeIcon,
keyboard: I.KeyboardIcon,
listBullet: I.ListBulletIcon,
magicWand: I.MagicWandIcon,
magnifyingGlass: I.MagnifyingGlassIcon,

View File

@@ -13,8 +13,6 @@ export function useCreateEnvironment() {
const prompt = usePrompt();
const workspaceId = useActiveWorkspaceId();
const queryClient = useQueryClient();
const environments = useEnvironments();
const workspaces = useWorkspaces();
return useMutation<Environment, unknown, void>({
mutationFn: async () => {
@@ -26,7 +24,7 @@ export function useCreateEnvironment() {
});
return invoke('create_environment', { name, variables: [], workspaceId });
},
onSettled: () => trackEvent('environment', 'create'),
onSettled: () => trackEvent('Environment', 'Create'),
onSuccess: async (environment) => {
if (workspaceId == null) return;
routes.setEnvironment(environment);

View File

@@ -18,7 +18,7 @@ export function useCreateFolder() {
patch.sortPriority = patch.sortPriority || -Date.now();
return invoke('create_folder', { workspaceId, ...patch });
},
onSettled: () => trackEvent('folder', 'create'),
onSettled: () => trackEvent('Folder', 'Create'),
onSuccess: async (request) => {
await queryClient.invalidateQueries(foldersQueryKey({ workspaceId: request.workspaceId }));
},

View File

@@ -28,7 +28,7 @@ export function useCreateRequest() {
patch.folderId = patch.folderId || activeRequest?.folderId;
return invoke('create_request', { workspaceId, name: '', ...patch });
},
onSettled: () => trackEvent('http_request', 'create'),
onSettled: () => trackEvent('HttpRequest', 'Create'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),

View File

@@ -12,7 +12,7 @@ export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }
mutationFn: (patch) => {
return invoke('create_workspace', patch);
},
onSettled: () => trackEvent('workspace', 'create'),
onSettled: () => trackEvent('Workspace', 'Create'),
onSuccess: async (workspace) => {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
...(workspaces ?? []),

View File

@@ -28,7 +28,7 @@ export function useDeleteAnyRequest() {
if (!confirmed) return null;
return invoke('delete_request', { requestId: id });
},
onSettled: () => trackEvent('http_request', 'delete'),
onSettled: () => trackEvent('HttpRequest', 'Delete'),
onSuccess: async (request) => {
// Was it cancelled?
if (request === null) return;

View File

@@ -24,7 +24,7 @@ export function useDeleteEnvironment(environment: Environment | null) {
if (!confirmed) return null;
return invoke('delete_environment', { environmentId: environment?.id });
},
onSettled: () => trackEvent('environment', 'delete'),
onSettled: () => trackEvent('Environment', 'Delete'),
onSuccess: async (environment) => {
if (environment === null) return;

View File

@@ -27,7 +27,7 @@ export function useDeleteFolder(id: string | null) {
if (!confirmed) return null;
return invoke('delete_folder', { folderId: id });
},
onSettled: () => trackEvent('folder', 'delete'),
onSettled: () => trackEvent('Folder', 'Delete'),
onSuccess: async (folder) => {
// Was it cancelled?
if (folder === null) return;

View File

@@ -10,7 +10,7 @@ export function useDeleteResponse(id: string | null) {
mutationFn: async () => {
return await invoke('delete_response', { id: id });
},
onSettled: () => trackEvent('http_response', 'delete'),
onSettled: () => trackEvent('HttpResponse', 'Delete'),
onSuccess: ({ requestId, id: responseId }) => {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey({ requestId }), (responses) =>
(responses ?? []).filter((response) => response.id !== responseId),

View File

@@ -10,7 +10,7 @@ export function useDeleteResponses(requestId?: string) {
if (requestId === undefined) return;
await invoke('delete_all_responses', { requestId });
},
onSettled: () => trackEvent('http_response', 'delete_many'),
onSettled: () => trackEvent('HttpResponse', 'DeleteMany'),
onSuccess: async () => {
if (requestId === undefined) return;
queryClient.setQueryData(responsesQueryKey({ requestId }), []);

View File

@@ -29,7 +29,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) {
if (!confirmed) return null;
return invoke('delete_workspace', { workspaceId: workspace?.id });
},
onSettled: () => trackEvent('workspace', 'delete'),
onSettled: () => trackEvent('Workspace', 'Delete'),
onSuccess: async (workspace) => {
if (workspace === null) return;

View File

@@ -23,7 +23,7 @@ export function useDuplicateRequest({
if (id === null) throw new Error("Can't duplicate a null request");
return invoke('duplicate_request', { id });
},
onSettled: () => trackEvent('http_request', 'duplicate'),
onSettled: () => trackEvent('HttpRequest', 'Duplicate'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),

View File

@@ -22,6 +22,8 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'environmentEditor.toggle': ['CmdCtrl+e'],
};
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
interface Options {
enable?: boolean;
}

View File

@@ -10,7 +10,7 @@ export function useSendAnyRequest() {
const alert = useAlert();
return useMutation<HttpResponse, string, string | null>({
mutationFn: (id) => invoke('send_request', { requestId: id, environmentId }),
onSettled: () => trackEvent('http_request', 'send'),
onSettled: () => trackEvent('HttpRequest', 'Send'),
onError: (err) => alert({ title: 'Export Failed', body: err }),
});
}

View File

@@ -10,6 +10,5 @@ export function useSendManyRequests() {
sendAnyRequest.mutate(id);
}
},
onSettled: () => trackEvent('http_request', 'send'),
});
}

View File

@@ -1,58 +1,20 @@
import { getVersion } from '@tauri-apps/api/app';
import type { Environment, Folder, HttpRequest, HttpResponse, KeyValue, Workspace } from './models';
const appVersion = await getVersion();
import { invoke } from '@tauri-apps/api';
export function trackEvent(
resource:
| Workspace['model']
| Environment['model']
| Folder['model']
| HttpRequest['model']
| HttpResponse['model']
| KeyValue['model'],
event: 'create' | 'update' | 'delete' | 'delete_many' | 'send' | 'duplicate',
| 'App'
| 'Workspace'
| 'Environment'
| 'Folder'
| 'HttpRequest'
| 'HttpResponse'
| 'KeyValue',
action: 'Launch' | 'Create' | 'Update' | 'Delete' | 'DeleteMany' | 'Send' | 'Duplicate',
attributes: Record<string, string | number> = {},
) {
send('/e', [
{ name: 'e', value: `${resource}.${event}` },
{ name: 'a', value: JSON.stringify({ ...attributes, version: appVersion }) },
]);
}
export function trackPage(pathname: string) {
if (pathname === sessionStorage.lastPathName) {
return;
}
sessionStorage.lastPathName = pathname;
send('/p', [
{
name: 'h',
value: 'desktop.yaak.app',
},
{ name: 'p', value: pathname },
]);
}
function send(path: string, params: { name: string; value: string | number }[]) {
if (localStorage.disableAnalytics === 'true') {
console.log('Analytics disabled', path, params);
}
params.push({ name: 'id', value: 'site_zOK0d7jeBy2TLxFCnZ' });
params.push({
name: 'tz',
value: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
params.push({ name: 'xy', value: screensize() });
const qs = params.map((v) => `${v.name}=${encodeURIComponent(v.value)}`).join('&');
const url = `https://t.yaak.app/t${path}?${qs}`;
fetch(url, { mode: 'no-cors' }).catch((err) => console.log('Error:', err));
}
function screensize() {
const w = window.screen.width;
const h = window.screen.height;
return `${Math.round(w / 100) * 100}x${Math.round(h / 100) * 100}`;
invoke('track_event', {
resource: resource,
action,
attributes,
}).catch(console.error);
}