Add basic analytics

This commit is contained in:
Gregory Schier
2023-11-07 09:53:59 -08:00
parent 6ccc42dc3f
commit 22f182a8eb
21 changed files with 133 additions and 78 deletions

View File

@@ -74,7 +74,7 @@ async fn send_ephemeral_request(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let response = models::HttpResponse::default();
let response = models::HttpResponse::new();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
request.id = "".to_string();
return actually_send_request(request, &response, &environment_id2, &app_handle, pool).await;

View File

@@ -118,6 +118,15 @@ pub struct HttpResponse {
pub headers: Json<Vec<HttpResponseHeader>>,
}
impl HttpResponse {
pub(crate) fn new() -> Self {
Self {
model: "http_response".to_string(),
..Default::default()
}
}
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct KeyValue {

View File

@@ -1,20 +1,22 @@
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';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { requestsQueryKey } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { responsesQueryKey } from '../hooks/useResponses';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { trackPage } from '../lib/analytics';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';
import { setPathname } from '../lib/persistPathname';
export function GlobalHooks() {
@@ -30,9 +32,13 @@ export function GlobalHooks() {
// Listen for location changes and update the pathname
const location = useLocation();
useEffect(() => {
setPathname(location.pathname);
setPathname(location.pathname).catch(console.error);
}, [location.pathname]);
useEffectOnce(() => {
trackPage('/');
});
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
@@ -82,9 +88,8 @@ export function GlobalHooks() {
}
if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(
queryKey,
(values) => values?.map((v) => (modelsEq(v, payload) ? payload : v)),
queryClient.setQueryData<Model[]>(queryKey, (values) =>
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
);
}
});

View File

@@ -30,7 +30,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
<HStack alignItems="center">
<WorkspaceActionsDropdown
leftSlot={
<div className="w-5 h-5 leading-5 rounded-sm text-[0.8em] bg-[#1B88DE] bg-opacity-80 text-white mr-1">
<div className="w-4 h-4 leading-4 rounded text-[0.8em] bg-[#1B88DE] bg-opacity-80 text-white mr-1">
{activeWorkspace?.name[0]?.toUpperCase()}
</div>
}

View File

@@ -1,9 +1,10 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { Environment } from '../lib/models';
import { environmentsQueryKey, useEnvironments } from './useEnvironments';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { environmentsQueryKey, useEnvironments } from './useEnvironments';
import { usePrompt } from './usePrompt';
import { useWorkspaces } from './useWorkspaces';
@@ -29,6 +30,7 @@ export function useCreateEnvironment() {
: [];
return invoke('create_environment', { name, variables, workspaceId });
},
onSettled: () => trackEvent('environment', 'create'),
onSuccess: async (environment) => {
if (workspaceId == null) return;
routes.setEnvironment(environment);

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { Folder } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { foldersQueryKey } from './useFolders';
@@ -17,6 +18,7 @@ export function useCreateFolder() {
patch.sortPriority = patch.sortPriority || Date.now();
return invoke('create_folder', { workspaceId, ...patch });
},
onSettled: () => trackEvent('folder', 'create'),
onSuccess: async (request) => {
await queryClient.invalidateQueries(foldersQueryKey({ workspaceId: request.workspaceId }));
},

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { HttpRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
@@ -26,6 +27,7 @@ export function useCreateRequest() {
patch.sortPriority = patch.sortPriority || maxSortPriority(requests) + 1000;
return invoke('create_request', { workspaceId, ...patch });
},
onSettled: () => trackEvent('http_request', 'create'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { Workspace } from '../lib/models';
import { useAppRoutes } from './useAppRoutes';
import { workspacesQueryKey } from './useWorkspaces';
@@ -11,6 +12,7 @@ export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }
mutationFn: (patch) => {
return invoke('create_workspace', patch);
},
onSettled: () => trackEvent('workspace', 'create'),
onSuccess: async (workspace) => {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
...(workspaces ?? []),

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import type { HttpRequest } from '../lib/models';
import { getRequest } from '../lib/store';
import { useConfirm } from './useConfirm';
@@ -26,6 +27,7 @@ export function useDeleteAnyRequest() {
if (!confirmed) return null;
return invoke('delete_request', { requestId: id });
},
onSettled: () => trackEvent('http_request', 'delete'),
onSuccess: async (request) => {
// Was it cancelled?
if (request === null) return;

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import type { Environment, Workspace } from '../lib/models';
import { useConfirm } from './useConfirm';
import { environmentsQueryKey } from './useEnvironments';
@@ -23,13 +24,13 @@ export function useDeleteEnvironment(environment: Environment | null) {
if (!confirmed) return null;
return invoke('delete_environment', { environmentId: environment?.id });
},
onSettled: () => trackEvent('environment', 'delete'),
onSuccess: async (environment) => {
if (environment === null) return;
const { id: environmentId, workspaceId } = environment;
queryClient.setQueryData<Workspace[]>(
environmentsQueryKey({ workspaceId }),
(environments) => environments?.filter((e) => e.id !== environmentId),
queryClient.setQueryData<Workspace[]>(environmentsQueryKey({ workspaceId }), (environments) =>
environments?.filter((e) => e.id !== environmentId),
);
},
});

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import type { Folder } from '../lib/models';
import { getFolder } from '../lib/store';
import { useConfirm } from './useConfirm';
@@ -26,6 +27,7 @@ export function useDeleteFolder(id: string | null) {
if (!confirmed) return null;
return invoke('delete_folder', { folderId: id });
},
onSettled: () => trackEvent('folder', 'delete'),
onSuccess: async (folder) => {
// Was it cancelled?
if (folder === null) return;

View File

@@ -1,40 +1,11 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { useMutation } from '@tanstack/react-query';
import type { HttpRequest } from '../lib/models';
import { getRequest } from '../lib/store';
import { useConfirm } from './useConfirm';
import { requestsQueryKey } from './useRequests';
import { responsesQueryKey } from './useResponses';
import { useDeleteAnyRequest } from './useDeleteAnyRequest';
export function useDeleteRequest(id: string | null) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const deleteAnyRequest = useDeleteAnyRequest();
return useMutation<HttpRequest | null, string>({
mutationFn: async () => {
const request = await getRequest(id);
const confirmed = await confirm({
title: 'Delete Request',
variant: 'delete',
description: (
<>
Permanently delete <InlineCode>{request?.name}</InlineCode>?
</>
),
});
if (!confirmed) return null;
return invoke('delete_request', { requestId: id });
},
onSuccess: async (request) => {
// Was it cancelled?
if (request === null) return;
const { workspaceId, id: requestId } = request;
queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey({ workspaceId }), (requests) =>
(requests ?? []).filter((r) => r.id !== requestId),
);
},
mutationFn: () => deleteAnyRequest.mutateAsync(id ?? 'n/a'),
});
}

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { HttpResponse } from '../lib/models';
import { responsesQueryKey } from './useResponses';
@@ -9,6 +10,7 @@ export function useDeleteResponse(id: string | null) {
mutationFn: async () => {
return await invoke('delete_response', { id: id });
},
onSettled: () => trackEvent('http_response', 'delete'),
onSuccess: ({ requestId, id: responseId }) => {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey({ requestId }), (responses) =>
(responses ?? []).filter((response) => response.id !== responseId),

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import { responsesQueryKey } from './useResponses';
export function useDeleteResponses(requestId?: string) {
@@ -9,6 +10,7 @@ export function useDeleteResponses(requestId?: string) {
if (requestId === undefined) return;
await invoke('delete_all_responses', { requestId });
},
onSettled: () => trackEvent('http_response', 'delete_many'),
onSuccess: async () => {
if (requestId === undefined) return;
queryClient.setQueryData(responsesQueryKey({ requestId }), []);

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import type { Workspace } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
@@ -28,6 +29,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) {
if (!confirmed) return null;
return invoke('delete_workspace', { workspaceId: workspace?.id });
},
onSettled: () => trackEvent('workspace', 'delete'),
onSuccess: async (workspace) => {
if (workspace === null) return;

View File

@@ -1,10 +1,11 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { HttpRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { requestsQueryKey } from './useRequests';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
export function useDuplicateRequest({
id,
@@ -22,6 +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'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),

View File

@@ -6,8 +6,8 @@ import { minPromiseMillis } from '../lib/minPromiseMillis';
import type { HttpRequest } from '../lib/models';
import { getResponseBodyText } from '../lib/responseBody';
import { sendEphemeralRequest } from '../lib/sendEphemeralRequest';
import { useDebouncedValue } from './useDebouncedValue';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useDebouncedValue } from './useDebouncedValue';
const introspectionRequestBody = JSON.stringify({
query: getIntrospectionQuery(),
@@ -66,7 +66,7 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
runIntrospection(); // Run immediately
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [request.id, request.url, request.method, refetchKey]);
}, [request.id, request.url, request.method, refetchKey, activeEnvironmentId]);
const refetch = useCallback(() => {
setRefetchKey((k) => k + 1);

View File

@@ -1,5 +1,6 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { HttpResponse } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
@@ -7,5 +8,6 @@ export function useSendAnyRequest() {
const environmentId = useActiveEnvironmentId();
return useMutation<HttpResponse, string, string | null>({
mutationFn: (id) => invoke('send_request', { requestId: id, environmentId }),
onSettled: () => trackEvent('http_request', 'send'),
});
}

View File

@@ -7,20 +7,28 @@ import { requestsQueryKey } from './useRequests';
export function useUpdateAnyRequest() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { id: string; update: (r: HttpRequest) => HttpRequest }>({
return useMutation<
void,
unknown,
{ id: string; update: Partial<HttpRequest> | ((r: HttpRequest) => HttpRequest) }
>({
mutationFn: async ({ id, update }) => {
const request = await getRequest(id);
if (request === null) {
throw new Error("Can't update a null request");
}
await invoke('update_request', { request: update(request) });
const patchedRequest =
typeof update === 'function' ? update(request) : { ...request, ...update };
await invoke('update_request', { request: patchedRequest });
},
onMutate: async ({ id, update }) => {
const request = await getRequest(id);
if (request === null) return;
const patchedRequest =
typeof update === 'function' ? update(request) : { ...request, ...update };
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(request), (requests) =>
(requests ?? []).map((r) => (r.id === request.id ? update(r) : r)),
(requests ?? []).map((r) => (r.id === patchedRequest.id ? patchedRequest : r)),
);
},
});

View File

@@ -1,29 +1,10 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useMutation } from '@tanstack/react-query';
import type { HttpRequest } from '../lib/models';
import { getRequest } from '../lib/store';
import { requestsQueryKey } from './useRequests';
import { useUpdateAnyRequest } from './useUpdateAnyRequest';
export function useUpdateRequest(id: string | null) {
const queryClient = useQueryClient();
const updateAnyRequest = useUpdateAnyRequest();
return useMutation<void, unknown, Partial<HttpRequest> | ((r: HttpRequest) => HttpRequest)>({
mutationFn: async (v) => {
const request = await getRequest(id);
if (request == null) {
throw new Error("Can't update a null request");
}
const newRequest = typeof v === 'function' ? v(request) : { ...request, ...v };
await invoke('update_request', { request: newRequest });
},
onMutate: async (v) => {
const request = await getRequest(id);
if (request === null) return;
const patchedRequest = typeof v === 'function' ? v(request) : { ...request, ...v };
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(request), (requests) =>
(requests ?? []).map((r) => (r.id === patchedRequest.id ? patchedRequest : r)),
);
},
mutationFn: async (update) => updateAnyRequest.mutateAsync({ id: id ?? 'n/a', update }),
});
}

58
src-web/lib/analytics.ts Normal file
View File

@@ -0,0 +1,58 @@
import { getVersion } from '@tauri-apps/api/app';
import type { Environment, Folder, HttpRequest, HttpResponse, KeyValue, Workspace } from './models';
const appVersion = await getVersion();
export function trackEvent(
resource:
| Workspace['model']
| Environment['model']
| Folder['model']
| HttpRequest['model']
| HttpResponse['model']
| KeyValue['model'],
event: 'create' | 'update' | 'delete' | 'delete_many' | '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}`;
}