diff --git a/package-lock.json b/package-lock.json index e05d27e3ebe8..b6ffab6be4ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3013,9 +3013,9 @@ } }, "node_modules/@getinsomnia/insomnia-v3-fetch": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/@getinsomnia/insomnia-v3-fetch/-/insomnia-v3-fetch-1.0.20.tgz", - "integrity": "sha512-5hBO9nfd9Dq+Hw1HTGjqkTTUahJB2ZBQOUbS8dz37JYjg8TJpuE4gfUXAGZpK2EDYU1NJZdQVvcz3eTunZEn3A==", + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@getinsomnia/insomnia-v3-fetch/-/insomnia-v3-fetch-1.0.27.tgz", + "integrity": "sha512-yQjIAi1UdEmOrRvYRczhLVlY41rbNm0Y518vl0AO7m1Uwwmgm4e40IqV7NKRwaR7+IVTmOVc+HobhEDmsa7hxA==", "license": "Apache-2.0" }, "node_modules/@getinsomnia/node-libcurl": { @@ -27904,7 +27904,7 @@ "version": "13.1.0", "license": "Apache-2.0", "dependencies": { - "@getinsomnia/insomnia-v3-fetch": "^1.0.20" + "@getinsomnia/insomnia-v3-fetch": "^1.0.27" } }, "packages/insomnia-data": { diff --git a/packages/insomnia-api/package.json b/packages/insomnia-api/package.json index c3c94aeb2afd..7dbb0438fb1c 100644 --- a/packages/insomnia-api/package.json +++ b/packages/insomnia-api/package.json @@ -26,6 +26,6 @@ "test": "vitest run" }, "dependencies": { - "@getinsomnia/insomnia-v3-fetch": "^1.0.20" + "@getinsomnia/insomnia-v3-fetch": "^1.0.27" } } diff --git a/packages/insomnia-api/src/__tests__/user.test.ts b/packages/insomnia-api/src/__tests__/user.test.ts index a6b2ec44104f..9577c20217d8 100644 --- a/packages/insomnia-api/src/__tests__/user.test.ts +++ b/packages/insomnia-api/src/__tests__/user.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getEncryptionKeys, getOnboardingState, getUserProfile } from '../user'; +import { getEncryptionKeys, getOnboardingState, getUserProfile, trackUserAction } from '../user'; const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn(), @@ -114,6 +114,23 @@ describe('getEncryptionKeys', () => { }); }); +describe('trackUserAction', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls fetch with the correct method, path, sessionId, and body', async () => { + await trackUserAction({ sessionId: 'sess_xyz', eventId: 'evt_123', actionType: 'request_created' }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/users/me/actions', + sessionId: 'sess_xyz', + data: { event_id: 'evt_123', action_type: 'request_created' }, + }); + }); +}); + describe('getOnboardingState', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/insomnia-api/src/user.ts b/packages/insomnia-api/src/user.ts index 7ce05272feb9..da71d0cafe5e 100644 --- a/packages/insomnia-api/src/user.ts +++ b/packages/insomnia-api/src/user.ts @@ -1,10 +1,10 @@ -import type { User, UserEncryptionKeys, UserOnboarding } from '@getinsomnia/insomnia-v3-fetch'; -import { UserOnboardingFirstRequestTreatmentEnum } from '@getinsomnia/insomnia-v3-fetch'; +import type { CurrentUserActionCreate, User, UserEncryptionKeys, UserOnboarding } from '@getinsomnia/insomnia-v3-fetch'; +import { CurrentUserActionCreateActionTypeEnum, UserOnboardingFirstRequestTreatmentEnum } from '@getinsomnia/insomnia-v3-fetch'; import { fetch } from './fetch'; -export type { User, UserEncryptionKeys, UserOnboarding }; -export { UserOnboardingFirstRequestTreatmentEnum }; +export type { CurrentUserActionCreate, User, UserEncryptionKeys, UserOnboarding }; +export { CurrentUserActionCreateActionTypeEnum, UserOnboardingFirstRequestTreatmentEnum }; // POST /auth/logout export const logout = ({ sessionId }: { sessionId: string }) => { @@ -43,6 +43,25 @@ export const latchRequestThresholdReached = async ({ sessionId }: { sessionId: s }); }; +// POST /v3/users/me/actions +export const trackUserAction = async ({ + sessionId, + eventId, + actionType, +}: { + sessionId: string; + eventId: string; + actionType: CurrentUserActionCreateActionTypeEnum; +}): Promise => { + const data: CurrentUserActionCreate = { event_id: eventId, action_type: actionType }; + return fetch({ + method: 'POST', + path: '/v3/users/me/actions', + sessionId, + data, + }); +}; + // GET /v1/billing/current-plan export type PersonalPlanType = 'free' | 'individual' | 'team' | 'enterprise' | 'enterprise-member'; type PaymentSchedules = 'month' | 'year'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx index 881d57b81717..9038c625a391 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx @@ -32,6 +32,7 @@ import { } from '~/network/network'; import { AnalyticsEvent, type ImportAttribution, importAttributionKey } from '~/ui/analytics'; import { createFetcherSubmitHook } from '~/ui/utils/router'; +import { trackUserActivity } from '~/ui/utils/track-user-activity'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send'; @@ -411,6 +412,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) }, }); + trackUserActivity('request_executed'); + const attributionStorageKey = importAttributionKey(requestId); const jsonImportAttribution = window.localStorage.getItem(attributionStorageKey); if (jsonImportAttribution) { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new.tsx index e4e3be41e55f..c8ed83f7d8f6 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new.tsx @@ -17,6 +17,7 @@ import { trackCioEvent } from '~/ui/hooks/use-cio'; import type { CreateRequestType } from '~/ui/hooks/use-request'; import { maybeLatchRequestThreshold } from '~/ui/utils/first-request-latch'; import { createFetcherSubmitHook } from '~/ui/utils/router'; +import { trackUserActivity } from '~/ui/utils/track-user-activity'; // Request types that are edited in the RequestPane / RequestUrlBar and should focus the URL on create. const URL_BAR_REQUEST_TYPES: CreateRequestType[] = ['HTTP', 'GraphQL', 'Event Stream', 'From Curl']; @@ -164,6 +165,8 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) // email-bearing profile (only fires when logged in). See INS-2678. trackCioEvent(AnalyticsEvent.requestCreated, requestCreatedProperties); + trackUserActivity('request_created'); + return redirect( href(`/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId`, { organizationId, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx index 4907df642983..a5e4c064d4a4 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx @@ -13,6 +13,7 @@ import { showToast } from '~/ui/components/toast-notification'; import { trackCioEvent } from '~/ui/hooks/use-cio'; import { maybeLatchRequestThreshold } from '~/ui/utils/first-request-latch'; import { createFetcherSubmitHook } from '~/ui/utils/router'; +import { trackUserActivity } from '~/ui/utils/track-user-activity'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.new'; import { mockRouteToHar } from './organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId'; @@ -191,6 +192,10 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) }, }); + if (event === AnalyticsEvent.documentCreate) { + trackUserActivity('document_created'); + } + if (workspaceData.withRequest) { const activeRequestId = ( await services.request.create({ @@ -220,6 +225,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) // user's email-bearing profile (only fires when logged in). See INS-2678. trackCioEvent(AnalyticsEvent.requestCreated, requestCreatedProperties); + trackUserActivity('request_created'); + if (!redirectAfterCreate) { return { workspaceId: workspace._id, diff --git a/packages/insomnia/src/ui/utils/track-user-activity.test.ts b/packages/insomnia/src/ui/utils/track-user-activity.test.ts new file mode 100644 index 000000000000..e6f6802daf13 --- /dev/null +++ b/packages/insomnia/src/ui/utils/track-user-activity.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockTrackUserAction, mockGetCurrentSessionId, mockGetAccountId } = vi.hoisted(() => ({ + mockTrackUserAction: vi.fn(), + mockGetCurrentSessionId: vi.fn(), + mockGetAccountId: vi.fn(), +})); + +vi.mock('insomnia-api', () => ({ + trackUserAction: mockTrackUserAction, +})); + +vi.mock('~/common/account/session', () => ({ + getCurrentSessionId: mockGetCurrentSessionId, + getAccountId: mockGetAccountId, +})); + +vi.mock('uuid', () => ({ + v4: () => 'evt_123', +})); + +import { trackUserActivity } from './track-user-activity'; + +describe('trackUserActivity', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('no-ops when there is no session', async () => { + mockGetCurrentSessionId.mockResolvedValue(null); + + await trackUserActivity('request_created'); + + expect(mockTrackUserAction).not.toHaveBeenCalled(); + }); + + it('no-ops when there is no account id', async () => { + mockGetCurrentSessionId.mockResolvedValue('sess_xyz'); + mockGetAccountId.mockResolvedValue(null); + + await trackUserActivity('request_created'); + + expect(mockTrackUserAction).not.toHaveBeenCalled(); + }); + + it('no-ops when the current user is not on an enterprise plan', async () => { + mockGetCurrentSessionId.mockResolvedValue('sess_xyz'); + mockGetAccountId.mockResolvedValue('acct_123'); + localStorage.setItem('acct_123:currentPlan', JSON.stringify({ type: 'individual' })); + + await trackUserActivity('request_created'); + + expect(mockTrackUserAction).not.toHaveBeenCalled(); + }); + + it('no-ops when there is no cached plan', async () => { + mockGetCurrentSessionId.mockResolvedValue('sess_xyz'); + mockGetAccountId.mockResolvedValue('acct_123'); + + await trackUserActivity('request_created'); + + expect(mockTrackUserAction).not.toHaveBeenCalled(); + }); + + it('tracks the action for an enterprise plan', async () => { + mockGetCurrentSessionId.mockResolvedValue('sess_xyz'); + mockGetAccountId.mockResolvedValue('acct_123'); + localStorage.setItem('acct_123:currentPlan', JSON.stringify({ type: 'enterprise' })); + + await trackUserActivity('request_created'); + + expect(mockTrackUserAction).toHaveBeenCalledWith({ + sessionId: 'sess_xyz', + eventId: 'evt_123', + actionType: 'request_created', + }); + }); + + it('tracks the action for an enterprise-member plan', async () => { + mockGetCurrentSessionId.mockResolvedValue('sess_xyz'); + mockGetAccountId.mockResolvedValue('acct_123'); + localStorage.setItem('acct_123:currentPlan', JSON.stringify({ type: 'enterprise-member' })); + + await trackUserActivity('request_executed'); + + expect(mockTrackUserAction).toHaveBeenCalledWith({ + sessionId: 'sess_xyz', + eventId: 'evt_123', + actionType: 'request_executed', + }); + }); + + it('does not throw when trackUserAction rejects', async () => { + mockGetCurrentSessionId.mockResolvedValue('sess_xyz'); + mockGetAccountId.mockResolvedValue('acct_123'); + localStorage.setItem('acct_123:currentPlan', JSON.stringify({ type: 'enterprise' })); + mockTrackUserAction.mockRejectedValue(new Error('network error')); + + await expect(trackUserActivity('document_created')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/insomnia/src/ui/utils/track-user-activity.ts b/packages/insomnia/src/ui/utils/track-user-activity.ts new file mode 100644 index 000000000000..de4d17e1189d --- /dev/null +++ b/packages/insomnia/src/ui/utils/track-user-activity.ts @@ -0,0 +1,36 @@ +import { type CurrentPlan, type CurrentUserActionCreateActionTypeEnum, trackUserAction } from 'insomnia-api'; +import { v4 as uuidv4 } from 'uuid'; + +import { getAccountId, getCurrentSessionId } from '~/common/account/session'; + +const isCurrentAccountOnEnterprisePlan = async (): Promise => { + const accountId = await getAccountId(); + if (!accountId) { + return false; + } + + const currentPlan = JSON.parse(localStorage.getItem(`${accountId}:currentPlan`) || '{}') as CurrentPlan; + return currentPlan?.type === 'enterprise' || currentPlan?.type === 'enterprise-member'; +}; + +/** + * POST /v3/users/me/actions. Fire-and-forget: never throws, no-ops when logged out or when the current user isn't on an enterprise plan. + * Call sites should not await this so it never blocks the calling flow. + */ +export const trackUserActivity = async (actionType: CurrentUserActionCreateActionTypeEnum): Promise => { + try { + const sessionId = await getCurrentSessionId(); + if (!sessionId) { + return; + } + + const isEnterpriseMember = await isCurrentAccountOnEnterprisePlan(); + if (!isEnterpriseMember) { + return; + } + + await trackUserAction({ sessionId, eventId: uuidv4(), actionType }); + } catch (error) { + console.error('Failed to track user activity', error); + } +};