Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/insomnia-api/src/__tests__/user.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions packages/insomnia-api/src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ export const latchRequestThresholdReached = async ({ sessionId }: { sessionId: s
});
};

// POST /v3/users/me/actions
export type UserActionType = 'request_created' | 'request_executed' | 'document_created';

export const trackUserAction = async ({
sessionId,
eventId,
actionType,
}: {
sessionId: string;
eventId: string;
actionType: UserActionType;
}): Promise<void> => {
return fetch<void>({
method: 'POST',
path: '/v3/users/me/actions',
Comment thread
fiosman marked this conversation as resolved.
sessionId,
data: { event_id: eventId, action_type: actionType },
});
Comment thread
fiosman marked this conversation as resolved.
};

// GET /v1/billing/current-plan
export type PersonalPlanType = 'free' | 'individual' | 'team' | 'enterprise' | 'enterprise-member';
type PaymentSchedules = 'month' | 'year';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
103 changes: 103 additions & 0 deletions packages/insomnia/src/ui/utils/track-user-activity.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
36 changes: 36 additions & 0 deletions packages/insomnia/src/ui/utils/track-user-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type CurrentPlan, trackUserAction, type UserActionType } from 'insomnia-api';
import { v4 as uuidv4 } from 'uuid';

import { getAccountId, getCurrentSessionId } from '~/common/account/session';

const isCurrentAccountOnEnterprisePlan = async (): Promise<boolean> => {
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: UserActionType): Promise<void> => {
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);
}
};
Loading