Skip to content

Commit 144d0ff

Browse files
authored
feat: digest cs experiment (#3812)
1 parent 78eeda6 commit 144d0ff

9 files changed

Lines changed: 288 additions & 6 deletions

File tree

.infra/Pulumi.prod.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ config:
261261
secure: AAABABe5GePYDxcK+9QGqmQV7zE3Pv5wWtGkfFOV1MosH+KVFIfK6Is=
262262
googleIosClientId:
263263
secure: AAABAENK/HL3DH78uShJwePe91GmOlM4YkNuaLsXkPrWp8p6tvW5XAlTMCN5XCSFCkQog94YZHEO0idbBTKPF1H727qb7XvUqVjkEP8yY7Sf77geK2VHUN+NMAkkeuaFar5dlJUkqCY=
264+
snotraUserApiOrigin:
265+
secure: AAABADX/8nmDxoZRpWxaAYCBL52wANtG5kR34DGnQgBkZKd0DKaxc8DQyXbRt/XuhNfj2+vI3xfDeSThDWlzKQFCNK0fAO4IUcuRBdDx6Q==
264266
api:k8s:
265267
host: subs.daily.dev
266268
namespace: daily

__tests__/integrations/snotra.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import nock from 'nock';
2+
import { SnotraClient } from '../../src/integrations/snotra/clients';
3+
import { PersonaliseState } from '../../src/integrations/snotra/types';
4+
5+
const url = 'http://snotra.local:3000';
6+
7+
beforeEach(() => {
8+
nock.cleanAll();
9+
});
10+
11+
describe('SnotraClient.getUserProfile', () => {
12+
it('should POST to /api/v1/user/profile and return parsed response', async () => {
13+
let capturedBody: unknown;
14+
nock(url)
15+
.post('/api/v1/user/profile', (body) => {
16+
capturedBody = body;
17+
return true;
18+
})
19+
.reply(200, {
20+
personalise: { state: PersonaliseState.Personalised },
21+
});
22+
23+
const client = new SnotraClient(url);
24+
const response = await client.getUserProfile({
25+
user_id: 'u1',
26+
providers: { personalise: {} },
27+
});
28+
29+
expect(capturedBody).toEqual({
30+
user_id: 'u1',
31+
providers: { personalise: {} },
32+
});
33+
expect(response).toEqual({
34+
personalise: { state: PersonaliseState.Personalised },
35+
});
36+
});
37+
});

__tests__/workers/personalizedDigestEmail.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ import {
4646
NotificationPreferenceStatus,
4747
NotificationType,
4848
} from '../../src/notifications/common';
49+
import { personalizedDigestSnotraClient } from '../../src/common/personalizedDigest';
50+
import { PersonaliseState } from '../../src/integrations/snotra/types';
51+
import { FeedConfigName } from '../../src/integrations/feed/types';
4952

5053
jest.mock('../../src/common', () => ({
5154
...(jest.requireActual('../../src/common') as Record<string, unknown>),
@@ -1192,6 +1195,101 @@ describe('personalizedDigestEmail worker', () => {
11921195
});
11931196
});
11941197

1198+
describe('digest_cs_v1 experiment', () => {
1199+
const experimentConfig = {
1200+
templateId: '48',
1201+
maxPosts: 5,
1202+
feedConfig: FeedConfigName.DigestCsV1,
1203+
};
1204+
1205+
it('should use digest_v2 when snotra reports any state other than non_personalised', async () => {
1206+
const spy = jest
1207+
.spyOn(personalizedDigestSnotraClient, 'getUserProfile')
1208+
.mockResolvedValue({
1209+
personalise: { state: 'some_other_state' as PersonaliseState },
1210+
});
1211+
1212+
const personalizedDigest = await con
1213+
.getRepository(UserPersonalizedDigest)
1214+
.findOneBy({ userId: '1' });
1215+
1216+
await expectSuccessfulBackground(worker, {
1217+
personalizedDigest,
1218+
...getDates(personalizedDigest!, Date.now()),
1219+
emailBatchId: 'test-email-batch-id',
1220+
config: experimentConfig,
1221+
});
1222+
1223+
expect(spy).toHaveBeenCalledWith({
1224+
user_id: '1',
1225+
providers: { personalise: {} },
1226+
});
1227+
expect(nockBody.feed_config_name).toBe(FeedConfigName.DigestV2);
1228+
});
1229+
1230+
it('should use digest_cs_v1 when snotra reports user is non_personalised', async () => {
1231+
jest
1232+
.spyOn(personalizedDigestSnotraClient, 'getUserProfile')
1233+
.mockResolvedValue({
1234+
personalise: { state: PersonaliseState.NonPersonalised },
1235+
});
1236+
1237+
const personalizedDigest = await con
1238+
.getRepository(UserPersonalizedDigest)
1239+
.findOneBy({ userId: '1' });
1240+
1241+
await expectSuccessfulBackground(worker, {
1242+
personalizedDigest,
1243+
...getDates(personalizedDigest!, Date.now()),
1244+
emailBatchId: 'test-email-batch-id',
1245+
config: experimentConfig,
1246+
});
1247+
1248+
expect(nockBody.feed_config_name).toBe(FeedConfigName.DigestCsV1);
1249+
});
1250+
1251+
it('should fall back to digest_v2 when snotra call fails', async () => {
1252+
jest
1253+
.spyOn(personalizedDigestSnotraClient, 'getUserProfile')
1254+
.mockRejectedValue(new Error('snotra down'));
1255+
1256+
const personalizedDigest = await con
1257+
.getRepository(UserPersonalizedDigest)
1258+
.findOneBy({ userId: '1' });
1259+
1260+
await expectSuccessfulBackground(worker, {
1261+
personalizedDigest,
1262+
...getDates(personalizedDigest!, Date.now()),
1263+
emailBatchId: 'test-email-batch-id',
1264+
config: experimentConfig,
1265+
});
1266+
1267+
expect(nockBody.feed_config_name).toBe(FeedConfigName.DigestV2);
1268+
});
1269+
1270+
it('should not call snotra when feedConfig is not the experiment marker', async () => {
1271+
const spy = jest.spyOn(personalizedDigestSnotraClient, 'getUserProfile');
1272+
1273+
const personalizedDigest = await con
1274+
.getRepository(UserPersonalizedDigest)
1275+
.findOneBy({ userId: '1' });
1276+
1277+
await expectSuccessfulBackground(worker, {
1278+
personalizedDigest,
1279+
...getDates(personalizedDigest!, Date.now()),
1280+
emailBatchId: 'test-email-batch-id',
1281+
config: {
1282+
templateId: '48',
1283+
maxPosts: 5,
1284+
feedConfig: 'some_other_config',
1285+
},
1286+
});
1287+
1288+
expect(spy).not.toHaveBeenCalled();
1289+
expect(nockBody.feed_config_name).toBe('some_other_config');
1290+
});
1291+
});
1292+
11951293
it('should generate personalized digest without an ad for plus members', async () => {
11961294
const personalizedDigest = await con
11971295
.getRepository(UserPersonalizedDigest)

src/common/personalizedDigest.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import { queryReadReplica } from './queryReadReplica';
3636
import { counters } from '../telemetry/metrics';
3737
import { SkadiAd, skadiPersonalizedDigestClient } from '../integrations/skadi';
3838
import { NotificationType } from '../notifications/common';
39+
import { SnotraClient } from '../integrations/snotra/clients';
40+
import { PersonaliseState } from '../integrations/snotra/types';
3941

4042
type TemplatePostData = Pick<
4143
ArticlePost,
@@ -283,6 +285,84 @@ const personalizedDigestFeedClient = new FeedClient(
283285
},
284286
);
285287

288+
export const personalizedDigestSnotraClient = new SnotraClient(
289+
process.env.SNOTRA_USER_API_ORIGIN as string,
290+
{
291+
fetchOptions: {
292+
timeout: 10 * 1000,
293+
},
294+
garmr: new GarmrService({
295+
service: 'snotra-client-digest',
296+
breakerOpts: {
297+
halfOpenAfter: 5 * 1000,
298+
threshold: 0.1,
299+
duration: 10 * 1000,
300+
minimumRps: 1,
301+
},
302+
limits: {
303+
maxRequests: 300,
304+
queuedRequests: 1000,
305+
},
306+
retryOpts: {
307+
maxAttempts: 1,
308+
},
309+
events: {
310+
onBreak: ({ meta }) => {
311+
counters?.['personalized-digest']?.garmrBreak?.add(1, {
312+
service: meta.service,
313+
});
314+
},
315+
onHalfOpen: ({ meta }) => {
316+
counters?.['personalized-digest']?.garmrHalfOpen?.add(1, {
317+
service: meta.service,
318+
});
319+
},
320+
onReset: ({ meta }) => {
321+
counters?.['personalized-digest']?.garmrReset?.add(1, {
322+
service: meta.service,
323+
});
324+
},
325+
onRetry: ({ meta }) => {
326+
counters?.['personalized-digest']?.garmrRetry?.add(1, {
327+
service: meta.service,
328+
});
329+
},
330+
},
331+
}),
332+
},
333+
);
334+
335+
const resolveDigestFeedConfigName = async ({
336+
personalizedDigest,
337+
feature,
338+
logger,
339+
}: {
340+
personalizedDigest: UserPersonalizedDigest;
341+
feature: PersonalizedDigestFeatureConfig;
342+
logger: FastifyBaseLogger;
343+
}): Promise<FeedConfigName> => {
344+
if (feature.feedConfig !== FeedConfigName.DigestCsV1) {
345+
return feature.feedConfig as FeedConfigName;
346+
}
347+
348+
try {
349+
const profile = await personalizedDigestSnotraClient.getUserProfile({
350+
user_id: personalizedDigest.userId,
351+
providers: { personalise: {} },
352+
});
353+
354+
return profile.personalise.state === PersonaliseState.NonPersonalised
355+
? FeedConfigName.DigestCsV1
356+
: FeedConfigName.DigestV2;
357+
} catch (err) {
358+
logger.error(
359+
{ err, personalizedDigest },
360+
'failed to fetch snotra user profile for digest, falling back to digest_v2',
361+
);
362+
return FeedConfigName.DigestV2;
363+
}
364+
};
365+
286366
export type DigestEmailPayloadResult = {
287367
emailPayload: SendEmailRequestWithTemplate;
288368
postIds: string[];
@@ -319,6 +399,12 @@ export const getPersonalizedDigestEmailPayload = async ({
319399
);
320400
});
321401

402+
const feedConfigName = await resolveDigestFeedConfigName({
403+
personalizedDigest,
404+
feature,
405+
logger,
406+
});
407+
322408
const feedConfigPayload = {
323409
user_id: personalizedDigest.userId,
324410
total_posts: feature.maxPosts,
@@ -327,7 +413,7 @@ export const getPersonalizedDigestEmailPayload = async ({
327413
allowed_tags: feedConfig.includeTags,
328414
blocked_tags: feedConfig.blockedTags,
329415
blocked_sources: feedConfig.excludeSources,
330-
feed_config_name: feature.feedConfig as FeedConfigName,
416+
feed_config_name: feedConfigName,
331417
source_types:
332418
baseFeedConfig.source_types?.filter(
333419
(el) => !feedConfig.excludeSourceTypes?.includes(el),

src/integrations/feed/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export enum FeedConfigName {
4747
// currently used when sorting custom feed by other option then recommended
4848
CustomFeedNaV1 = 'custom_feed_na_v1',
4949
ForYouByDate = 'for_you_by_date',
50+
DigestV2 = 'digest_v2',
51+
DigestCsV1 = 'digest_cs_v1',
5052
}
5153

5254
export type FeedProvider = {

src/integrations/retry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
ATTR_URL_FULL,
1010
} from '@opentelemetry/semantic-conventions';
1111
import { Message } from '@bufbuild/protobuf';
12-
import { isTest } from '../common';
12+
import { isTest } from '../common/utils';
1313

1414
export class AbortError extends Error {
1515
public originalError: Error;

src/integrations/snotra/clients.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import { RequestInit } from 'node-fetch';
22
import { GarmrNoopService, IGarmrService, GarmrService } from '../garmr';
33
import { fetchOptions as globalFetchOptions } from '../../http';
44
import { retryFetchParse } from '../retry';
5-
import { ISnotraClient, ProfileRequest, ProfileResponse } from './types';
5+
import {
6+
ISnotraClient,
7+
ProfileRequest,
8+
ProfileResponse,
9+
UserProfileRequest,
10+
UserProfileResponse,
11+
} from './types';
612
import {
713
isMockEnabled,
814
mockSnotraEngagementProfile,
15+
mockSnotraUserProfile,
916
} from '../../mocks/opportunity/services';
1017

1118
export class SnotraClient implements ISnotraClient {
@@ -48,6 +55,27 @@ export class SnotraClient implements ISnotraClient {
4855
);
4956
});
5057
}
58+
59+
getUserProfile(request: UserProfileRequest): Promise<UserProfileResponse> {
60+
// Mock path: return mock user profile
61+
if (isMockEnabled()) {
62+
return Promise.resolve(mockSnotraUserProfile);
63+
}
64+
65+
return this.garmr.execute(() => {
66+
return retryFetchParse<UserProfileResponse>(
67+
`${this.url}/api/v1/user/profile`,
68+
{
69+
...this.fetchOptions,
70+
method: 'POST',
71+
headers: {
72+
'Content-Type': 'application/json',
73+
},
74+
body: JSON.stringify(request),
75+
},
76+
);
77+
});
78+
}
5179
}
5280

5381
const garmrSnotraService = new GarmrService({

src/integrations/snotra/types.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
11
// Keep the type flexible to allow for future changes
2-
export interface ProfileRequest {
2+
export type ProfileRequest = {
33
user_id: string;
4-
}
4+
};
55

6-
export interface ProfileResponse {
6+
export type ProfileResponse = {
77
profile_text: string;
88
update_at: string;
9+
};
10+
11+
export enum PersonaliseState {
12+
Personalised = 'personalised',
13+
NonPersonalised = 'non_personalised',
914
}
1015

16+
export type UserProfileRequest = {
17+
user_id: string;
18+
providers: {
19+
personalise: Record<string, never>;
20+
};
21+
};
22+
23+
export type UserProfileResponse = {
24+
personalise: {
25+
state: PersonaliseState;
26+
};
27+
};
28+
1129
export interface ISnotraClient {
1230
getProfile(request: ProfileRequest): Promise<ProfileResponse>;
31+
getUserProfile(request: UserProfileRequest): Promise<UserProfileResponse>;
1332
}

src/mocks/opportunity/services.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
SalaryPeriod,
1010
SeniorityLevel,
1111
} from '@dailydotdev/schema';
12+
import { PersonaliseState } from '../../integrations/snotra';
1213

1314
/**
1415
* Check if external services should be mocked
@@ -123,3 +124,12 @@ export const mockSnotraEngagementProfile = {
123124
'Active developer with strong engagement in React and TypeScript communities. Regular contributor to open source projects and frequent reader of frontend development content. Shows consistent interest in modern web technologies and best practices.',
124125
update_at: new Date().toISOString(),
125126
};
127+
128+
/**
129+
* Mock user profile returned from Snotra getUserProfile
130+
*/
131+
export const mockSnotraUserProfile = {
132+
personalise: {
133+
state: PersonaliseState.Personalised,
134+
},
135+
};

0 commit comments

Comments
 (0)