Skip to content

Commit ab929e4

Browse files
authored
Merge pull request #1832 from guardian/nz-non-auxia-sign-in-gate
Add Gandalf sign-in gate rules and Auxia bypass
2 parents 577d1b6 + 4a52f25 commit ab929e4

16 files changed

Lines changed: 780 additions & 31 deletions

docs/auxia.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
## Auxia
2+
23
Auxia is a service that uses ML models to optimise messaging.
34

45
We use it in support-dotcom-components. Currently there are two uses:
6+
57
- [Sign-in gate](signinGate.md)
68
- Banners
79

@@ -12,9 +14,11 @@ support-dotcom-components uses the API to find out if a message should be displa
1214
## Uses
1315

1416
### Sign-in gate
17+
1518
[See separate doc.](signinGate.md)
1619

1720
### Banners
21+
1822
We are trialling using Auxia for banner decision making.
1923

2024
In the first experiment we ask Auxia whether or not to suppress the banner. If Auxia does not suppress the banner then we use the existing rules.
@@ -26,3 +30,11 @@ Auxia is consulted when the `enableAuxiaForBanners` switch is on, a browserId is
2630
If Auxia is consulted and a banner is returned then we also track events on the client using Auxia's `LogTreatmentInteraction` endpoint. We proxy these requests via `/banner/interaction`.
2731

2832
See [auxia.ts](../src/server/lib/auxia.ts) for implementation.
33+
34+
### Gandalf bypass
35+
36+
While the `enableGandalfSignInGate` channel switch is on, New Zealand readers
37+
are never sent to Auxia for sign-in gates: the journey is fully
38+
Guardian-managed (see [signinGate.md](signinGate.md)). Banner behaviour is
39+
unchanged — the banner suppression checker consults Auxia exactly as before,
40+
for every country.

docs/signinGate.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
## Sign-in gate
2+
23
DCR displays sign-in gates on article pages.
34
Some of these gates are managed by Auxia. Auxia is a third-party that uses ML to optimise messaging on the site.
45

56
### Architecture
7+
68
[Architecture diagram](https://docs.google.com/drawings/d/1zynyGMqXekhNFQpLkzAdHqyt9iQy_RQ-kR7jFGsU5K0/edit).
79

810
DCR article pages make a request to SDC's `/auxia/get-treatments` endpoint. This endpoint may return a "treatment", which is the configuration for a gate.
911

1012
SDC will decide which treatment (if any) to return based on either:
13+
1114
1. An API call to Auxia, for browsers which are eligible and consented,
1215
2. Hardcoded config in SDC, for all other browsers
1316

@@ -19,7 +22,38 @@ For details of the current configuration, see [logic.md](/src/server/signin-gate
1922

2023
SDC also has an endpoint for tracking interactions (view/click) with the gate: `/auxia/log-treatment-interaction`. These events are forwarded on to Auxia, and are independent of the standard Ophan tracking.
2124

25+
### Gandalf sign-in gate (Guardian-managed journey)
26+
27+
"Gandalf" is the marketing name for the Guardian-managed sign-in gate journey:
28+
a 100% rollout run entirely by Guardian rules, with no Auxia involvement. It is
29+
active for any reader whose country code is `NZ` when the
30+
`enableGandalfSignInGate` channel switch is on. For that surface SDC owns the
31+
rules entirely and Auxia is bypassed:
32+
33+
- the first two pageviews of the day are free (the response carries the
34+
`gandalfSignInGate` marker with no treatment, so DCR recognises the
35+
Guardian-managed decision but shows no gate);
36+
- from the third pageview of the day onwards SDC returns a hardcoded
37+
Guardian-managed non-dismissible popup treatment
38+
(`NONDISMISSIBLE_SIGN_IN_GATE_POPUP`). The trigger is the standard
39+
`dailyArticleCount` payload field (`gu.history.dailyArticleCount` on the
40+
client), which already includes the current pageview;
41+
- no Auxia GetTreatments or LogTreatmentInteraction request is made for either
42+
consent state;
43+
- the eligible surfaces are the Guardian metadata values Article, Network
44+
Front, Section, Tag, Audio, Crossword, Gallery, Interactive, LiveBlog,
45+
ImageContent and Video, minus the exclusions listed in
46+
[logic.md](/src/server/signin-gate/logic.md);
47+
- Ophan events use a stable Gandalf identity (`GandalfSignInGate`, variant
48+
`gandalf-nz`) instead of the Auxia test metadata. This is not an A/B test.
49+
50+
Toggling the switch is a configuration change (the Channel Switches UI).
51+
Switching it off — or the field being absent from `channel-switches.json`
52+
restores the previous behaviour, which is the rollback path.
53+
2254
### Data
55+
2356
From the Guardian's perspective, Auxia gets its data for ML model training and analytics in two ways:
57+
2458
1. ingestion of data from BigQuery (the datalake)
2559
2. log treatment interactions - view and click events that we send to their API, via SDC

src/server/api/auxiaProxyRouter.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,15 @@ export const buildAuxiaProxyRouter = (
7979
try {
8080
const now = Date.now(); // current time in milliseconds since epoch
8181
const payload = req.body as GetTreatmentsRequestPayload;
82-
const { enableAuxia } = channelSwitches.get();
83-
const gateType = getTreatmentsRequestPayloadToGateType(payload, now, enableAuxia);
82+
const { enableAuxia, enableGandalfSignInGate } = channelSwitches.get();
83+
const gateType = getTreatmentsRequestPayloadToGateType(
84+
payload,
85+
now,
86+
enableAuxia,
87+
// Tolerate old switch documents without the field: an
88+
// absent switch means the Gandalf journey is off.
89+
enableGandalfSignInGate ?? false,
90+
);
8491
const envelop = await gateTypeToUserTreatmentsEnvelop(config, gateType, payload);
8592
if (envelop !== undefined) {
8693
const data = userTreatmentsEnvelopToProxyGetTreatmentsAnswerData(envelop);

src/server/channelSwitches.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export interface ChannelSwitches {
1515
enableMParticle: boolean;
1616
enableAuxia: boolean; // for sign-in gates
1717
enableAuxiaForBanners: boolean;
18+
// Gandalf: marketing name for the Guardian-managed sign-in gate journey
19+
// (100% Guardian-owned rules, no Auxia). When switched on, New Zealand
20+
// readers run the Gandalf journey; off means it is disabled, which is the
21+
// rollback path.
22+
enableGandalfSignInGate?: boolean;
1823
}
1924

2025
const getSwitches = (): Promise<ChannelSwitches> =>

src/server/signin-gate/libEffect.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { AuxiaRouterConfig } from '../api/auxiaProxyRouter';
66
import {
77
buildGetTreatmentsRequestPayload,
88
buildLogTreatmentInteractionRequestPayload,
9+
gandalfMandatoryPopupUserTreatment,
910
guDismissibleUserTreatment,
1011
guMandatoryUserTreatment,
1112
} from './libPure';
@@ -158,6 +159,28 @@ export const gateTypeToUserTreatmentsEnvelop = async (
158159
responseId: '',
159160
userTreatments: [guMandatoryUserTreatment()],
160161
};
162+
// ----------------------------------------------------------
163+
// Gandalf: the Guardian-managed sign-in gate journey
164+
// (comment group: gandalf)
165+
//
166+
// Neither case calls Auxia. Both carry the gandalfSignInGate marker
167+
// so the client can count the completed pageview and identify the
168+
// Guardian-managed response.
169+
case 'GandalfFreeView':
170+
// No gate on this pageview: an empty userTreatments array produces
171+
// a response with no userTreatment, but the marker still tells the
172+
// client the pageview counted towards the free allowance.
173+
return {
174+
responseId: '',
175+
userTreatments: [],
176+
gandalfSignInGate: true,
177+
};
178+
case 'GandalfMandatoryPopup':
179+
return {
180+
responseId: '',
181+
userTreatments: [gandalfMandatoryPopupUserTreatment()],
182+
gandalfSignInGate: true,
183+
};
161184
default:
162185
console.error('Unknown direction');
163186
}

src/server/signin-gate/libPure.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,48 @@ export const guMandatoryUserTreatment = (): UserTreatment => {
124124
};
125125
};
126126

127+
export const gandalfMandatoryPopupUserTreatment = (): UserTreatment => {
128+
// (comment group: gandalf)
129+
//
130+
// "Gandalf" is the marketing name for the Guardian-managed sign-in gate
131+
// journey: a 100% rollout run entirely by Guardian rules with no Auxia
132+
// involvement (currently New Zealand, gated by the enableGandalfSignInGate
133+
// channel switch).
134+
//
135+
// The Guardian-managed hard gate. The copy matches guMandatoryUserTreatment,
136+
// but the treatmentType uses the POPUP variant so the client renders the v2
137+
// modal (mounted on document.body) instead of the inline article gate.
138+
//
139+
// The treatmentId is Gandalf-specific (not the shared
140+
// 'default-treatment-id') so Ophan component events for the Gandalf popup
141+
// are distinguishable in analysis. Auxia interaction suppression does not
142+
// depend on the treatmentId: the client skips Auxia calls for responses
143+
// carrying the gandalfSignInGate marker.
144+
145+
const title = "No, you don't need to pay to keep reading";
146+
const subtitle = "Simply sign in - it's free, and much quicker than you think";
147+
const body =
148+
'We’re committed to keeping our quality reporting open. By registering and providing us with insight into your preferences, you’re helping us to engage with you more deeply, and that allows us to keep our journalism free for all.';
149+
const treatmentContent = {
150+
title,
151+
subtitle,
152+
body,
153+
first_cta_name: 'Create an account',
154+
first_cta_link: 'https://profile.theguardian.com/signin',
155+
second_cta_name: '', // empty string here makes the gate mandatory
156+
};
157+
const treatmentContentEncoded = JSON.stringify(treatmentContent);
158+
return {
159+
treatmentId: 'gandalf-mandatory-popup',
160+
treatmentTrackingId: 'gandalf-mandatory-popup-tracking-id',
161+
rank: '1',
162+
contentLanguageCode: 'en-GB',
163+
treatmentContent: treatmentContentEncoded,
164+
treatmentType: 'NONDISMISSIBLE_SIGN_IN_GATE_POPUP',
165+
surface: 'ARTICLE_PAGE',
166+
};
167+
};
168+
127169
export const isValidContentType = (contentType: string): boolean => {
128170
const validTypes = ['Article'];
129171
return validTypes.includes(contentType);
@@ -138,6 +180,7 @@ export const isValidSection = (sectionId: string): boolean => {
138180
'guardian-live-australia',
139181
'gnm-archive',
140182
'thefilter',
183+
'thefilter-us',
141184
];
142185
return !invalidSections.includes(sectionId);
143186
};
@@ -148,6 +191,56 @@ export const isValidTagIds = (tagIds: string[]): boolean => {
148191
return !tagIds.some((tagId: string): boolean => invalidTagIds.includes(tagId));
149192
};
150193

194+
// --------------------------------------------------------------
195+
// Gandalf (comment group: gandalf)
196+
//
197+
// "Gandalf" is the marketing name for the Guardian-managed sign-in gate
198+
// journey: a 100% rollout, run entirely by Guardian rules with no Auxia
199+
// involvement, currently live for New Zealand and switched on via the
200+
// enableGandalfSignInGate channel switch.
201+
//
202+
// Gandalf widens the eligible content types while sharing the generic
203+
// sign-in-gate display exclusions used by every country.
204+
205+
// The exact Guardian content metadata values (see DotcomContentType in
206+
// guardian/frontend). Note that CAPI "Picture" pages are sent as ImageContent,
207+
// and fronts are sent as Network Front / Section / Tag.
208+
const gandalfContentTypes = [
209+
'Article',
210+
'Network Front',
211+
'Section',
212+
'Tag',
213+
'Audio',
214+
'Crossword',
215+
'Gallery',
216+
'Interactive',
217+
'LiveBlog',
218+
'ImageContent',
219+
'Video',
220+
];
221+
222+
export const gandalfIsValidContentType = (contentType: string): boolean => {
223+
// Case-insensitive so casing drift upstream cannot silently exclude a page.
224+
const validTypes = gandalfContentTypes.map((type) => type.toLowerCase());
225+
return validTypes.includes(contentType.toLowerCase());
226+
};
227+
228+
export const gandalfPageMetadataIsEligibleForGateDisplay = (
229+
contentType: string,
230+
sectionId: string,
231+
tagIds: string[],
232+
): boolean => {
233+
return (
234+
gandalfIsValidContentType(contentType) && isValidSection(sectionId) && isValidTagIds(tagIds)
235+
);
236+
};
237+
238+
// The free allowance: the first two pageviews of the day do not show a gate.
239+
// The client sends dailyArticleCount, the number of pageviews the reader has
240+
// already made today including the current one (1-based), so counts 1-2 are
241+
// free and 3+ shows the hard popup.
242+
export const GANDALF_FREE_PAGE_VIEW_COUNT = 2;
243+
151244
export const userTreatmentsEnvelopToProxyGetTreatmentsAnswerData = (
152245
envelop: UserTreatmentsEnvelop,
153246
): ProxyGetTreatmentsAnswerData | undefined => {
@@ -165,6 +258,12 @@ export const userTreatmentsEnvelopToProxyGetTreatmentsAnswerData = (
165258
return {
166259
responseId: envelop.responseId,
167260
userTreatment: envelop.userTreatments[0],
261+
// Only include the marker when set so responses for countries outside
262+
// the Gandalf list keep their exact previous shape (the existing tests
263+
// use toStrictEqual).
264+
...(envelop.gandalfSignInGate !== undefined && {
265+
gandalfSignInGate: envelop.gandalfSignInGate,
266+
}),
168267
};
169268
};
170269

@@ -203,6 +302,9 @@ export const articleIdentifierIsAllowed = (articleIdentifier: string): boolean =
203302
const denyPrefixes = [
204303
'www.theguardian.com/tips',
205304
'www.theguardian.com/help/ng-interactive/2017/mar/17/contact-the-guardian-securely',
305+
'www.theguardian.com/info/privacy',
306+
'www.theguardian.com/info/complaints-and-corrections',
307+
'www.theguardian.com/the-whole-picture',
206308
];
207309

208310
return !denyPrefixes.some((denyIdentifer) => articleIdentifier.startsWith(denyIdentifer));
@@ -345,16 +447,67 @@ export const getTreatmentsRequestPayloadToGateType = (
345447
payload: GetTreatmentsRequestPayload,
346448
now: number,
347449
enableAuxia: boolean,
450+
enableGandalfSignInGate: boolean | undefined,
348451
): GateType => {
349452
// now: current time in milliseconds since epoch
350453
// enableAuxia: channel switch to enable/disable Auxia integration
454+
// enableGandalfSignInGate: channel switch turning on the Gandalf
455+
// sign-in gate journey (see channelSwitches.ts); undefined or false
456+
// disables the journey
351457

352458
// This function is a pure function (without any side effects) which gets the body
353459
// of a '/auxia/get-treatments' request and returns the correct GateType
354460
// It was introduced to separate the choice of the gate from it's actual build,
355461
// which in the case of Auxia, requires an API call, but more importantly to
356462
// encapsulate and more logically test the logic of gate selection.
357463

464+
// --------------------------------------------------------------
465+
// Gandalf: the Guardian-managed sign-in gate journey
466+
// (comment group: gandalf; "Gandalf" is the marketing name for this
467+
// Guardian-owned, Auxia-free 100% rollout)
468+
//
469+
// Prerequisites:
470+
// - the enableGandalfSignInGate channel switch is on (the journey is
471+
// currently New Zealand only)
472+
//
473+
// Effects:
474+
// - Guardian drives the gate, Auxia is never consulted (no GetTreatments
475+
// and no LogTreatmentInteraction for either consent state)
476+
// - the first two pageviews of the day are free (the response carries
477+
// the gandalfSignInGate marker with no treatment, so the client knows
478+
// this is a Guardian-managed decision and shows no gate)
479+
// - from the third daily pageview onwards the Guardian-managed
480+
// non-dismissible popup is returned
481+
//
482+
// The special cases below (URL denials, page eligibility, newsshowcase
483+
// override and the staff testing feature) are deliberately evaluated with
484+
// the generic display exclusions. Pages excluded here return 'None'
485+
// without the marker.
486+
487+
if (enableGandalfSignInGate === true && payload.countryCode === 'NZ') {
488+
if (!articleIdentifierIsAllowed(payload.articleIdentifier)) {
489+
return 'None';
490+
}
491+
if (
492+
!gandalfPageMetadataIsEligibleForGateDisplay(
493+
payload.contentType,
494+
payload.sectionId,
495+
payload.tagIds,
496+
)
497+
) {
498+
return 'None';
499+
}
500+
if (isOverridingConditionShowDismissibleGate(payload)) {
501+
return 'GuDismissible';
502+
}
503+
if (isStaffTestConditionShowDefaultGate(payload)) {
504+
return staffTestConditionToDefaultGate(payload);
505+
}
506+
return payload.dailyArticleCount <= GANDALF_FREE_PAGE_VIEW_COUNT
507+
? 'GandalfFreeView'
508+
: 'GandalfMandatoryPopup';
509+
}
510+
358511
// --------------------------------------------------------------
359512
// We do not show the gate on some specific article urls
360513

src/server/signin-gate/libPureTests/articleIdentifierIsAllowed.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,9 @@ it('articleIdentifierIsAllowed', () => {
99
expect(articleIdentifierIsAllowed('www.theguardian.com/tips')).toBe(false);
1010
expect(articleIdentifierIsAllowed('www.theguardian.com/tips#test')).toBe(false);
1111
expect(articleIdentifierIsAllowed('www.theguardian.com/tips/test')).toBe(false);
12+
expect(articleIdentifierIsAllowed('www.theguardian.com/info/privacy')).toBe(false);
13+
expect(articleIdentifierIsAllowed('www.theguardian.com/info/complaints-and-corrections')).toBe(
14+
false,
15+
);
16+
expect(articleIdentifierIsAllowed('www.theguardian.com/the-whole-picture')).toBe(false);
1217
});

0 commit comments

Comments
 (0)