Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .infra/Pulumi.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@ config:
secure: AAABABe5GePYDxcK+9QGqmQV7zE3Pv5wWtGkfFOV1MosH+KVFIfK6Is=
googleIosClientId:
secure: AAABAENK/HL3DH78uShJwePe91GmOlM4YkNuaLsXkPrWp8p6tvW5XAlTMCN5XCSFCkQog94YZHEO0idbBTKPF1H727qb7XvUqVjkEP8yY7Sf77geK2VHUN+NMAkkeuaFar5dlJUkqCY=
snotraUserApiOrigin:
secure: AAABADX/8nmDxoZRpWxaAYCBL52wANtG5kR34DGnQgBkZKd0DKaxc8DQyXbRt/XuhNfj2+vI3xfDeSThDWlzKQFCNK0fAO4IUcuRBdDx6Q==
api:k8s:
host: subs.daily.dev
namespace: daily
Expand Down
37 changes: 37 additions & 0 deletions __tests__/integrations/snotra.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import nock from 'nock';
import { SnotraClient } from '../../src/integrations/snotra/clients';
import { PersonaliseState } from '../../src/integrations/snotra/types';

const url = 'http://snotra.local:3000';

beforeEach(() => {
nock.cleanAll();
});

describe('SnotraClient.getUserProfile', () => {
it('should POST to /api/v1/user/profile and return parsed response', async () => {
let capturedBody: unknown;
nock(url)
.post('/api/v1/user/profile', (body) => {
capturedBody = body;
return true;
})
.reply(200, {
personalise: { state: PersonaliseState.Personalised },
});

const client = new SnotraClient(url);
const response = await client.getUserProfile({
user_id: 'u1',
providers: { personalise: {} },
});

expect(capturedBody).toEqual({
user_id: 'u1',
providers: { personalise: {} },
});
expect(response).toEqual({
personalise: { state: PersonaliseState.Personalised },
});
});
});
98 changes: 98 additions & 0 deletions __tests__/workers/personalizedDigestEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ import {
NotificationPreferenceStatus,
NotificationType,
} from '../../src/notifications/common';
import { personalizedDigestSnotraClient } from '../../src/common/personalizedDigest';
import { PersonaliseState } from '../../src/integrations/snotra/types';
import { FeedConfigName } from '../../src/integrations/feed/types';

jest.mock('../../src/common', () => ({
...(jest.requireActual('../../src/common') as Record<string, unknown>),
Expand Down Expand Up @@ -1192,6 +1195,101 @@ describe('personalizedDigestEmail worker', () => {
});
});

describe('digest_cs_v1 experiment', () => {
const experimentConfig = {
templateId: '48',
maxPosts: 5,
feedConfig: FeedConfigName.DigestCsV1,
};

it('should use digest_v2 when snotra reports any state other than non_personalised', async () => {
const spy = jest
.spyOn(personalizedDigestSnotraClient, 'getUserProfile')
.mockResolvedValue({
personalise: { state: 'some_other_state' as PersonaliseState },
});

const personalizedDigest = await con
.getRepository(UserPersonalizedDigest)
.findOneBy({ userId: '1' });

await expectSuccessfulBackground(worker, {
personalizedDigest,
...getDates(personalizedDigest!, Date.now()),
emailBatchId: 'test-email-batch-id',
config: experimentConfig,
});

expect(spy).toHaveBeenCalledWith({
user_id: '1',
providers: { personalise: {} },
});
expect(nockBody.feed_config_name).toBe(FeedConfigName.DigestV2);
});

it('should use digest_cs_v1 when snotra reports user is non_personalised', async () => {
jest
.spyOn(personalizedDigestSnotraClient, 'getUserProfile')
.mockResolvedValue({
personalise: { state: PersonaliseState.NonPersonalised },
});

const personalizedDigest = await con
.getRepository(UserPersonalizedDigest)
.findOneBy({ userId: '1' });

await expectSuccessfulBackground(worker, {
personalizedDigest,
...getDates(personalizedDigest!, Date.now()),
emailBatchId: 'test-email-batch-id',
config: experimentConfig,
});

expect(nockBody.feed_config_name).toBe(FeedConfigName.DigestCsV1);
});

it('should fall back to digest_v2 when snotra call fails', async () => {
jest
.spyOn(personalizedDigestSnotraClient, 'getUserProfile')
.mockRejectedValue(new Error('snotra down'));

const personalizedDigest = await con
.getRepository(UserPersonalizedDigest)
.findOneBy({ userId: '1' });

await expectSuccessfulBackground(worker, {
personalizedDigest,
...getDates(personalizedDigest!, Date.now()),
emailBatchId: 'test-email-batch-id',
config: experimentConfig,
});

expect(nockBody.feed_config_name).toBe(FeedConfigName.DigestV2);
});

it('should not call snotra when feedConfig is not the experiment marker', async () => {
const spy = jest.spyOn(personalizedDigestSnotraClient, 'getUserProfile');

const personalizedDigest = await con
.getRepository(UserPersonalizedDigest)
.findOneBy({ userId: '1' });

await expectSuccessfulBackground(worker, {
personalizedDigest,
...getDates(personalizedDigest!, Date.now()),
emailBatchId: 'test-email-batch-id',
config: {
templateId: '48',
maxPosts: 5,
feedConfig: 'some_other_config',
},
});

expect(spy).not.toHaveBeenCalled();
expect(nockBody.feed_config_name).toBe('some_other_config');
});
});

it('should generate personalized digest without an ad for plus members', async () => {
const personalizedDigest = await con
.getRepository(UserPersonalizedDigest)
Expand Down
88 changes: 87 additions & 1 deletion src/common/personalizedDigest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import { queryReadReplica } from './queryReadReplica';
import { counters } from '../telemetry/metrics';
import { SkadiAd, skadiPersonalizedDigestClient } from '../integrations/skadi';
import { NotificationType } from '../notifications/common';
import { SnotraClient } from '../integrations/snotra/clients';
import { PersonaliseState } from '../integrations/snotra/types';

type TemplatePostData = Pick<
ArticlePost,
Expand Down Expand Up @@ -283,6 +285,84 @@ const personalizedDigestFeedClient = new FeedClient(
},
);

export const personalizedDigestSnotraClient = new SnotraClient(
process.env.SNOTRA_USER_API_ORIGIN as string,
{
fetchOptions: {
timeout: 10 * 1000,
},
garmr: new GarmrService({
service: 'snotra-client-digest',
breakerOpts: {
halfOpenAfter: 5 * 1000,
threshold: 0.1,
duration: 10 * 1000,
minimumRps: 1,
},
limits: {
maxRequests: 300,
queuedRequests: 1000,
},
retryOpts: {
maxAttempts: 1,
},
events: {
onBreak: ({ meta }) => {
counters?.['personalized-digest']?.garmrBreak?.add(1, {
service: meta.service,
});
},
onHalfOpen: ({ meta }) => {
counters?.['personalized-digest']?.garmrHalfOpen?.add(1, {
service: meta.service,
});
},
onReset: ({ meta }) => {
counters?.['personalized-digest']?.garmrReset?.add(1, {
service: meta.service,
});
},
onRetry: ({ meta }) => {
counters?.['personalized-digest']?.garmrRetry?.add(1, {
service: meta.service,
});
},
},
}),
},
);

const resolveDigestFeedConfigName = async ({
personalizedDigest,
feature,
logger,
}: {
personalizedDigest: UserPersonalizedDigest;
feature: PersonalizedDigestFeatureConfig;
logger: FastifyBaseLogger;
}): Promise<FeedConfigName> => {
if (feature.feedConfig !== FeedConfigName.DigestCsV1) {
return feature.feedConfig as FeedConfigName;
}

try {
const profile = await personalizedDigestSnotraClient.getUserProfile({
user_id: personalizedDigest.userId,
providers: { personalise: {} },
});

return profile.personalise.state === PersonaliseState.NonPersonalised
? FeedConfigName.DigestCsV1
: FeedConfigName.DigestV2;
} catch (err) {
logger.error(
{ err, personalizedDigest },
'failed to fetch snotra user profile for digest, falling back to digest_v2',
);
return FeedConfigName.DigestV2;
}
};

export type DigestEmailPayloadResult = {
emailPayload: SendEmailRequestWithTemplate;
postIds: string[];
Expand Down Expand Up @@ -319,6 +399,12 @@ export const getPersonalizedDigestEmailPayload = async ({
);
});

const feedConfigName = await resolveDigestFeedConfigName({
personalizedDigest,
feature,
logger,
});

const feedConfigPayload = {
user_id: personalizedDigest.userId,
total_posts: feature.maxPosts,
Expand All @@ -327,7 +413,7 @@ export const getPersonalizedDigestEmailPayload = async ({
allowed_tags: feedConfig.includeTags,
blocked_tags: feedConfig.blockedTags,
blocked_sources: feedConfig.excludeSources,
feed_config_name: feature.feedConfig as FeedConfigName,
feed_config_name: feedConfigName,
source_types:
baseFeedConfig.source_types?.filter(
(el) => !feedConfig.excludeSourceTypes?.includes(el),
Expand Down
2 changes: 2 additions & 0 deletions src/integrations/feed/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export enum FeedConfigName {
// currently used when sorting custom feed by other option then recommended
CustomFeedNaV1 = 'custom_feed_na_v1',
ForYouByDate = 'for_you_by_date',
DigestV2 = 'digest_v2',
DigestCsV1 = 'digest_cs_v1',
}

export type FeedProvider = {
Expand Down
2 changes: 1 addition & 1 deletion src/integrations/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
ATTR_URL_FULL,
} from '@opentelemetry/semantic-conventions';
import { Message } from '@bufbuild/protobuf';
import { isTest } from '../common';
import { isTest } from '../common/utils';

export class AbortError extends Error {
public originalError: Error;
Expand Down
30 changes: 29 additions & 1 deletion src/integrations/snotra/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@ import { RequestInit } from 'node-fetch';
import { GarmrNoopService, IGarmrService, GarmrService } from '../garmr';
import { fetchOptions as globalFetchOptions } from '../../http';
import { retryFetchParse } from '../retry';
import { ISnotraClient, ProfileRequest, ProfileResponse } from './types';
import {
ISnotraClient,
ProfileRequest,
ProfileResponse,
UserProfileRequest,
UserProfileResponse,
} from './types';
import {
isMockEnabled,
mockSnotraEngagementProfile,
mockSnotraUserProfile,
} from '../../mocks/opportunity/services';

export class SnotraClient implements ISnotraClient {
Expand Down Expand Up @@ -48,6 +55,27 @@ export class SnotraClient implements ISnotraClient {
);
});
}

getUserProfile(request: UserProfileRequest): Promise<UserProfileResponse> {
// Mock path: return mock user profile
if (isMockEnabled()) {
return Promise.resolve(mockSnotraUserProfile);
}

return this.garmr.execute(() => {
return retryFetchParse<UserProfileResponse>(
`${this.url}/api/v1/user/profile`,
{
...this.fetchOptions,
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
},
);
});
}
}

const garmrSnotraService = new GarmrService({
Expand Down
25 changes: 22 additions & 3 deletions src/integrations/snotra/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
// Keep the type flexible to allow for future changes
export interface ProfileRequest {
export type ProfileRequest = {
user_id: string;
}
};

export interface ProfileResponse {
export type ProfileResponse = {
profile_text: string;
update_at: string;
};

export enum PersonaliseState {
Personalised = 'personalised',
NonPersonalised = 'non_personalised',
}

export type UserProfileRequest = {
user_id: string;
providers: {
personalise: Record<string, never>;
};
};

export type UserProfileResponse = {
personalise: {
state: PersonaliseState;
};
};

export interface ISnotraClient {
getProfile(request: ProfileRequest): Promise<ProfileResponse>;
getUserProfile(request: UserProfileRequest): Promise<UserProfileResponse>;
}
10 changes: 10 additions & 0 deletions src/mocks/opportunity/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
SalaryPeriod,
SeniorityLevel,
} from '@dailydotdev/schema';
import { PersonaliseState } from '../../integrations/snotra';

/**
* Check if external services should be mocked
Expand Down Expand Up @@ -123,3 +124,12 @@ export const mockSnotraEngagementProfile = {
'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.',
update_at: new Date().toISOString(),
};

/**
* Mock user profile returned from Snotra getUserProfile
*/
export const mockSnotraUserProfile = {
personalise: {
state: PersonaliseState.Personalised,
},
};
Loading