Skip to content

Commit 4f8e85e

Browse files
authored
feat(core): add OSS onboarding survey reporting (#8666)
1 parent 99c399a commit 4f8e85e

11 files changed

Lines changed: 449 additions & 25 deletions

File tree

Dockerfile

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
# syntax=docker/dockerfile:1.7
2+
13
###### [STAGE] Build ######
2-
FROM node:22-alpine as builder
4+
FROM node:22-alpine AS builder
35
WORKDIR /etc/logto
46
ENV CI=true
57

@@ -14,7 +16,8 @@ RUN apk add --no-cache python3 make g++ rsync
1416
COPY . .
1517

1618
### Install dependencies and build ###
17-
RUN pnpm i
19+
# Reuse the pnpm store between BuildKit runs to reduce duplicate downloads/writes.
20+
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store pnpm i
1821

1922
### Set if dev features enabled ###
2023
ARG dev_features_enabled
@@ -23,6 +26,9 @@ ENV DEV_FEATURES_ENABLED=${dev_features_enabled}
2326
ARG applicationinsights_connection_string
2427
ENV APPLICATIONINSIGHTS_CONNECTION_STRING=${applicationinsights_connection_string}
2528

29+
ARG logto_oss_survey_endpoint=
30+
ENV LOGTO_OSS_SURVEY_ENDPOINT=${logto_oss_survey_endpoint}
31+
2632
RUN pnpm -r build
2733

2834
### Add official connectors ###
@@ -31,15 +37,19 @@ ENV ADDITIONAL_CONNECTOR_ARGS=${additional_connector_args}
3137
RUN pnpm cli connector link $ADDITIONAL_CONNECTOR_ARGS -p .
3238

3339
### Prune dependencies for production ###
34-
RUN rm -rf node_modules packages/**/node_modules
35-
RUN NODE_ENV=production pnpm i
40+
# Keep prune + production install in one layer to avoid extra transient disk usage.
41+
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
42+
rm -rf node_modules packages/**/node_modules && NODE_ENV=production pnpm i
3643

3744
### Clean up ###
3845
RUN rm -rf .scripts pnpm-*.yaml packages/cloud
3946

4047
###### [STAGE] Seal ######
41-
FROM node:22-alpine as app
48+
FROM node:22-alpine AS app
4249
WORKDIR /etc/logto
50+
ARG logto_oss_survey_endpoint=
51+
# Default to empty so external survey relaying stays opt-in for controlled builds/environments.
52+
ENV LOGTO_OSS_SURVEY_ENDPOINT=${logto_oss_survey_endpoint}
4353
COPY --from=builder /etc/logto .
4454
RUN mkdir -p /etc/logto/packages/cli/alteration-scripts && chmod g+w /etc/logto/packages/cli/alteration-scripts
4555
EXPOSE 3001

packages/console/src/consts/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ export const postHogHost = normalizeEnv(import.meta.env.POSTHOG_PUBLIC_HOST);
3333
* @see https://posthog.com/docs/libraries/js/config for more details.
3434
*/
3535
export const postHogUiHost = normalizeEnv(import.meta.env.POSTHOG_PUBLIC_UI_HOST);
36+
export const ossSurveyEndpoint = normalizeEnv(import.meta.env.LOGTO_OSS_SURVEY_ENDPOINT);

packages/console/src/hooks/use-oss-onboarding-data.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@ const useOssOnboardingData = (): {
3030

3131
const update = useCallback(
3232
async (data: Partial<OssUserOnboardingData>) => {
33-
// TODO: sync OSS onboarding submissions to a dedicated server-side endpoint for
34-
// analysis and future marketing email outreach instead of relying only on user custom data.
3533
await updateCustomData({
3634
[ossUserOnboardingDataKey]: {
3735
...ossOnboardingData,

packages/console/src/pages/OssOnboarding/index.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
2323
import { trySubmitSafe } from '@/utils/form';
2424

2525
import styles from './index.module.scss';
26+
import { submitOssOnboarding } from './submit-oss-onboarding';
2627
import {
2728
getOssOnboardingDefaultValues,
28-
getOssOnboardingSubmitPayload,
2929
shouldRequireCompanyFields,
3030
type OssOnboardingFormData,
3131
} from './utils';
@@ -67,13 +67,13 @@ function OssOnboarding() {
6767
}, [data.questionnaire, isLoading, reset]);
6868

6969
const onSubmit = handleSubmit(
70-
trySubmitSafe(async (formData) => {
71-
await update({
72-
questionnaire: getOssOnboardingSubmitPayload(formData),
73-
isOnboardingDone: true,
74-
});
75-
navigate('/get-started', { replace: true });
76-
})
70+
trySubmitSafe(async (formData) =>
71+
submitOssOnboarding({
72+
formData,
73+
navigate,
74+
update,
75+
})
76+
)
7777
);
7878

7979
if (isLoading) {
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { CompanySize, Project, type OssUserOnboardingData } from '@logto/schemas';
2+
import type { Options } from 'ky';
3+
4+
import type { OssOnboardingFormData } from './utils';
5+
6+
// Module-level mocks for env constants. Must be declared before importing the module under test.
7+
// eslint-disable-next-line @silverhand/fp/no-let
8+
let mockIsDevFeaturesEnabled = true;
9+
// eslint-disable-next-line @silverhand/fp/no-let
10+
let mockOssSurveyEndpoint: string | undefined = 'https://survey.example.com';
11+
12+
const mockKyPost = jest.fn<Promise<{ ok: boolean }>, [URL, Options?]>();
13+
const mockKyOptions: { current?: Options } = {};
14+
const mockKyCreate = jest.fn((options?: Options) => {
15+
// eslint-disable-next-line @silverhand/fp/no-mutation
16+
mockKyOptions.current = options;
17+
18+
return {
19+
post: async (...args: [URL, Options?]) => {
20+
const retryLimit =
21+
options?.retry && typeof options.retry !== 'number' ? (options.retry.limit ?? 0) : 0;
22+
const retryDelay =
23+
options?.retry && typeof options.retry !== 'number' ? (options.retry.delay?.(1) ?? 0) : 0;
24+
25+
try {
26+
const response = await mockKyPost(...args);
27+
return response;
28+
} catch (error) {
29+
if (retryLimit < 1 || retryDelay === 0) {
30+
throw error;
31+
}
32+
33+
const response = await mockKyPost(...args);
34+
return response;
35+
}
36+
},
37+
};
38+
});
39+
40+
jest.mock('ky', () => ({
41+
__esModule: true,
42+
default: {
43+
create: (...args: [Options?]) => mockKyCreate(...args),
44+
},
45+
}));
46+
47+
jest.mock('@/consts/env', () => ({
48+
get isDevFeaturesEnabled() {
49+
return mockIsDevFeaturesEnabled;
50+
},
51+
get ossSurveyEndpoint() {
52+
return mockOssSurveyEndpoint;
53+
},
54+
}));
55+
56+
const mockFormData: OssOnboardingFormData = {
57+
emailAddress: 'Dev@Example.COM',
58+
newsletter: true,
59+
project: Project.Company,
60+
companyName: 'Acme',
61+
companySize: CompanySize.Scale3,
62+
};
63+
64+
const getSubmitOssOnboarding = async () => {
65+
const module = await import('./submit-oss-onboarding');
66+
return module.submitOssOnboarding;
67+
};
68+
69+
describe('submitOssOnboarding', () => {
70+
beforeAll(async () => {
71+
await import('./submit-oss-onboarding');
72+
});
73+
74+
afterEach(() => {
75+
mockKyPost.mockReset();
76+
mockKyPost.mockResolvedValue({ ok: true });
77+
// eslint-disable-next-line @silverhand/fp/no-mutation
78+
mockKyOptions.current = undefined;
79+
// eslint-disable-next-line @silverhand/fp/no-mutation
80+
mockIsDevFeaturesEnabled = true;
81+
// eslint-disable-next-line @silverhand/fp/no-mutation
82+
mockOssSurveyEndpoint = 'https://survey.example.com';
83+
});
84+
85+
beforeEach(() => {
86+
mockKyPost.mockResolvedValue({ ok: true });
87+
});
88+
89+
it('configures the OSS survey client with ky retry options', () => {
90+
expect(mockKyCreate).toHaveBeenCalledTimes(1);
91+
expect(mockKyOptions.current?.throwHttpErrors).toBe(false);
92+
93+
const retry = mockKyOptions.current?.retry;
94+
95+
expect(retry && typeof retry !== 'number' ? retry.limit : undefined).toBe(1);
96+
expect(retry && typeof retry !== 'number' ? retry.methods : undefined).toEqual(['post']);
97+
expect(retry && typeof retry !== 'number' ? retry.statusCodes : undefined).toEqual([]);
98+
expect(retry && typeof retry !== 'number' ? retry.afterStatusCodes : undefined).toEqual([]);
99+
expect(retry && typeof retry !== 'number' ? retry.delay?.(1) : undefined).toBe(1);
100+
});
101+
102+
it('updates, reports and navigates when onboarding is submitted', async () => {
103+
const submitOssOnboarding = await getSubmitOssOnboarding();
104+
const update = jest.fn<Promise<void>, [Partial<OssUserOnboardingData>]>();
105+
const navigate = jest.fn<void, [string, { replace: boolean }]>();
106+
107+
update.mockResolvedValue();
108+
109+
await submitOssOnboarding({
110+
formData: mockFormData,
111+
navigate,
112+
update,
113+
});
114+
115+
expect(update).toHaveBeenCalledWith({
116+
questionnaire: {
117+
emailAddress: 'dev@example.com',
118+
newsletter: true,
119+
project: Project.Company,
120+
companyName: 'Acme',
121+
companySize: CompanySize.Scale3,
122+
},
123+
isOnboardingDone: true,
124+
});
125+
expect(mockKyPost).toHaveBeenCalledWith(
126+
new URL('https://survey.example.com/api/surveys'),
127+
expect.objectContaining({
128+
json: {
129+
emailAddress: 'dev@example.com',
130+
newsletter: true,
131+
project: Project.Company,
132+
companyName: 'Acme',
133+
companySize: CompanySize.Scale3,
134+
},
135+
keepalive: true,
136+
})
137+
);
138+
expect(navigate).toHaveBeenCalledWith('/get-started', { replace: true });
139+
});
140+
141+
it('constructs the reporting URL when endpoint has trailing slash', async () => {
142+
const submitOssOnboarding = await getSubmitOssOnboarding();
143+
const update = jest.fn<Promise<void>, [Partial<OssUserOnboardingData>]>();
144+
const navigate = jest.fn<void, [string, { replace: boolean }]>();
145+
146+
// eslint-disable-next-line @silverhand/fp/no-mutation
147+
mockOssSurveyEndpoint = 'https://survey.example.com/';
148+
update.mockResolvedValue();
149+
150+
await submitOssOnboarding({
151+
formData: mockFormData,
152+
navigate,
153+
update,
154+
});
155+
156+
expect(mockKyPost).toHaveBeenCalledWith(
157+
new URL('https://survey.example.com/api/surveys'),
158+
expect.anything()
159+
);
160+
});
161+
162+
it('still reports and navigates when onboarding data persistence fails', async () => {
163+
const submitOssOnboarding = await getSubmitOssOnboarding();
164+
const update = jest.fn<Promise<void>, [Partial<OssUserOnboardingData>]>();
165+
const navigate = jest.fn<void, [string, { replace: boolean }]>();
166+
167+
update.mockRejectedValue(new Error('save failed'));
168+
169+
await expect(
170+
submitOssOnboarding({
171+
formData: mockFormData,
172+
navigate,
173+
update,
174+
})
175+
).resolves.toBeUndefined();
176+
177+
expect(mockKyPost).toHaveBeenCalled();
178+
expect(navigate).toHaveBeenCalledWith('/get-started', { replace: true });
179+
});
180+
181+
it('retries report once after a network failure', async () => {
182+
const submitOssOnboarding = await getSubmitOssOnboarding();
183+
const update = jest.fn<Promise<void>, [Partial<OssUserOnboardingData>]>();
184+
const navigate = jest.fn<void, [string, { replace: boolean }]>();
185+
186+
mockKyPost.mockRejectedValueOnce(new TypeError('network error')).mockResolvedValueOnce({
187+
ok: true,
188+
});
189+
update.mockResolvedValue();
190+
191+
await expect(
192+
submitOssOnboarding({
193+
formData: mockFormData,
194+
navigate,
195+
update,
196+
})
197+
).resolves.toBeUndefined();
198+
199+
await new Promise<void>((resolve) => {
200+
setTimeout(resolve, 0);
201+
});
202+
203+
expect(mockKyPost).toHaveBeenCalledTimes(2);
204+
expect(navigate).toHaveBeenCalledWith('/get-started', { replace: true });
205+
});
206+
207+
it('swallows report fetch failures after retry to keep submit flow unchanged', async () => {
208+
const submitOssOnboarding = await getSubmitOssOnboarding();
209+
const update = jest.fn<Promise<void>, [Partial<OssUserOnboardingData>]>();
210+
const navigate = jest.fn<void, [string, { replace: boolean }]>();
211+
212+
mockKyPost.mockRejectedValue(new TypeError('network error'));
213+
update.mockResolvedValue();
214+
215+
await expect(
216+
submitOssOnboarding({
217+
formData: mockFormData,
218+
navigate,
219+
update,
220+
})
221+
).resolves.toBeUndefined();
222+
223+
await new Promise<void>((resolve) => {
224+
setTimeout(resolve, 0);
225+
});
226+
227+
expect(mockKyPost).toHaveBeenCalledTimes(2);
228+
expect(navigate).toHaveBeenCalledWith('/get-started', { replace: true });
229+
});
230+
231+
it('does not report when isDevFeaturesEnabled is false', async () => {
232+
const submitOssOnboarding = await getSubmitOssOnboarding();
233+
const update = jest.fn<Promise<void>, [Partial<OssUserOnboardingData>]>();
234+
const navigate = jest.fn<void, [string, { replace: boolean }]>();
235+
236+
// eslint-disable-next-line @silverhand/fp/no-mutation
237+
mockIsDevFeaturesEnabled = false;
238+
update.mockResolvedValue();
239+
240+
await submitOssOnboarding({
241+
formData: mockFormData,
242+
navigate,
243+
update,
244+
});
245+
246+
expect(mockKyPost).not.toHaveBeenCalled();
247+
expect(navigate).toHaveBeenCalledWith('/get-started', { replace: true });
248+
});
249+
250+
it('does not report when ossSurveyEndpoint is undefined', async () => {
251+
const submitOssOnboarding = await getSubmitOssOnboarding();
252+
const update = jest.fn<Promise<void>, [Partial<OssUserOnboardingData>]>();
253+
const navigate = jest.fn<void, [string, { replace: boolean }]>();
254+
255+
// eslint-disable-next-line @silverhand/fp/no-mutation
256+
mockOssSurveyEndpoint = undefined;
257+
update.mockResolvedValue();
258+
259+
await submitOssOnboarding({
260+
formData: mockFormData,
261+
navigate,
262+
update,
263+
});
264+
265+
expect(mockKyPost).not.toHaveBeenCalled();
266+
expect(navigate).toHaveBeenCalledWith('/get-started', { replace: true });
267+
});
268+
269+
it('does not report when ossSurveyEndpoint is invalid', async () => {
270+
const submitOssOnboarding = await getSubmitOssOnboarding();
271+
const update = jest.fn<Promise<void>, [Partial<OssUserOnboardingData>]>();
272+
const navigate = jest.fn<void, [string, { replace: boolean }]>();
273+
274+
// eslint-disable-next-line @silverhand/fp/no-mutation
275+
mockOssSurveyEndpoint = 'not a valid URL';
276+
update.mockResolvedValue();
277+
278+
await submitOssOnboarding({
279+
formData: mockFormData,
280+
navigate,
281+
update,
282+
});
283+
284+
expect(mockKyPost).not.toHaveBeenCalled();
285+
expect(navigate).toHaveBeenCalledWith('/get-started', { replace: true });
286+
});
287+
});

0 commit comments

Comments
 (0)