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
12 changes: 12 additions & 0 deletions docs/auxia.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
## Auxia

Auxia is a service that uses ML models to optimise messaging.

We use it in support-dotcom-components. Currently there are two uses:

- [Sign-in gate](signinGate.md)
- Banners

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

### Sign-in gate

[See separate doc.](signinGate.md)

### Banners

We are trialling using Auxia for banner decision making.

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.
Expand All @@ -26,3 +30,11 @@ Auxia is consulted when the `enableAuxiaForBanners` switch is on, a browserId is
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`.

See [auxia.ts](../src/server/lib/auxia.ts) for implementation.

### Gandalf bypass

While the `enableGandalfSignInGate` channel switch is on, New Zealand readers
are never sent to Auxia for sign-in gates: the journey is fully
Guardian-managed (see [signinGate.md](signinGate.md)). Banner behaviour is
unchanged — the banner suppression checker consults Auxia exactly as before,
for every country.
34 changes: 34 additions & 0 deletions docs/signinGate.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
## Sign-in gate

DCR displays sign-in gates on article pages.
Some of these gates are managed by Auxia. Auxia is a third-party that uses ML to optimise messaging on the site.

### Architecture

[Architecture diagram](https://docs.google.com/drawings/d/1zynyGMqXekhNFQpLkzAdHqyt9iQy_RQ-kR7jFGsU5K0/edit).

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.

SDC will decide which treatment (if any) to return based on either:

1. An API call to Auxia, for browsers which are eligible and consented,
2. Hardcoded config in SDC, for all other browsers

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

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.

### Gandalf sign-in gate (Guardian-managed journey)

"Gandalf" is the marketing name for the Guardian-managed sign-in gate journey:
a 100% rollout run entirely by Guardian rules, with no Auxia involvement. It is
active for any reader whose country code is `NZ` when the
`enableGandalfSignInGate` channel switch is on. For that surface SDC owns the
rules entirely and Auxia is bypassed:

- the first two pageviews of the day are free (the response carries the
`gandalfSignInGate` marker with no treatment, so DCR recognises the
Guardian-managed decision but shows no gate);
- from the third pageview of the day onwards SDC returns a hardcoded
Guardian-managed non-dismissible popup treatment
(`NONDISMISSIBLE_SIGN_IN_GATE_POPUP`). The trigger is the standard
`dailyArticleCount` payload field (`gu.history.dailyArticleCount` on the
client), which already includes the current pageview;
- no Auxia GetTreatments or LogTreatmentInteraction request is made for either
consent state;
- the eligible surfaces are the Guardian metadata values Article, Network
Front, Section, Tag, Audio, Crossword, Gallery, Interactive, LiveBlog,
ImageContent and Video, minus the exclusions listed in
[logic.md](/src/server/signin-gate/logic.md);
- Ophan events use a stable Gandalf identity (`GandalfSignInGate`, variant
`gandalf-nz`) instead of the Auxia test metadata. This is not an A/B test.

Toggling the switch is a configuration change (the Channel Switches UI).
Switching it off — or the field being absent from `channel-switches.json` —
restores the previous behaviour, which is the rollback path.

### Data

From the Guardian's perspective, Auxia gets its data for ML model training and analytics in two ways:

1. ingestion of data from BigQuery (the datalake)
2. log treatment interactions - view and click events that we send to their API, via SDC
11 changes: 9 additions & 2 deletions src/server/api/auxiaProxyRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,15 @@ export const buildAuxiaProxyRouter = (
try {
const now = Date.now(); // current time in milliseconds since epoch
const payload = req.body as GetTreatmentsRequestPayload;
const { enableAuxia } = channelSwitches.get();
const gateType = getTreatmentsRequestPayloadToGateType(payload, now, enableAuxia);
const { enableAuxia, enableGandalfSignInGate } = channelSwitches.get();
const gateType = getTreatmentsRequestPayloadToGateType(
payload,
now,
enableAuxia,
// Tolerate old switch documents without the field: an
// absent switch means the Gandalf journey is off.
enableGandalfSignInGate ?? false,
);
const envelop = await gateTypeToUserTreatmentsEnvelop(config, gateType, payload);
if (envelop !== undefined) {
const data = userTreatmentsEnvelopToProxyGetTreatmentsAnswerData(envelop);
Expand Down
5 changes: 5 additions & 0 deletions src/server/channelSwitches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface ChannelSwitches {
enableMParticle: boolean;
enableAuxia: boolean; // for sign-in gates
enableAuxiaForBanners: boolean;
// Gandalf: marketing name for the Guardian-managed sign-in gate journey
// (100% Guardian-owned rules, no Auxia). When switched on, New Zealand
// readers run the Gandalf journey; off means it is disabled, which is the
// rollback path.
enableGandalfSignInGate?: boolean;
}

const getSwitches = (): Promise<ChannelSwitches> =>
Expand Down
23 changes: 23 additions & 0 deletions src/server/signin-gate/libEffect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AuxiaRouterConfig } from '../api/auxiaProxyRouter';
import {
buildGetTreatmentsRequestPayload,
buildLogTreatmentInteractionRequestPayload,
gandalfMandatoryPopupUserTreatment,
guDismissibleUserTreatment,
guMandatoryUserTreatment,
} from './libPure';
Expand Down Expand Up @@ -158,6 +159,28 @@ export const gateTypeToUserTreatmentsEnvelop = async (
responseId: '',
userTreatments: [guMandatoryUserTreatment()],
};
// ----------------------------------------------------------
// Gandalf: the Guardian-managed sign-in gate journey
// (comment group: gandalf)
//
// Neither case calls Auxia. Both carry the gandalfSignInGate marker
// so the client can count the completed pageview and identify the
// Guardian-managed response.
case 'GandalfFreeView':
// No gate on this pageview: an empty userTreatments array produces
// a response with no userTreatment, but the marker still tells the
// client the pageview counted towards the free allowance.
return {
responseId: '',
userTreatments: [],
gandalfSignInGate: true,
};
case 'GandalfMandatoryPopup':
return {
responseId: '',
userTreatments: [gandalfMandatoryPopupUserTreatment()],
gandalfSignInGate: true,
};
default:
console.error('Unknown direction');
}
Expand Down
153 changes: 153 additions & 0 deletions src/server/signin-gate/libPure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,48 @@ export const guMandatoryUserTreatment = (): UserTreatment => {
};
};

export const gandalfMandatoryPopupUserTreatment = (): UserTreatment => {
// (comment group: gandalf)
//
// "Gandalf" is the marketing name for the Guardian-managed sign-in gate
// journey: a 100% rollout run entirely by Guardian rules with no Auxia
// involvement (currently New Zealand, gated by the enableGandalfSignInGate
// channel switch).
//
// The Guardian-managed hard gate. The copy matches guMandatoryUserTreatment,
// but the treatmentType uses the POPUP variant so the client renders the v2
// modal (mounted on document.body) instead of the inline article gate.
//
// The treatmentId is Gandalf-specific (not the shared
// 'default-treatment-id') so Ophan component events for the Gandalf popup
// are distinguishable in analysis. Auxia interaction suppression does not
// depend on the treatmentId: the client skips Auxia calls for responses
// carrying the gandalfSignInGate marker.

const title = "No, you don't need to pay to keep reading";
const subtitle = "Simply sign in - it's free, and much quicker than you think";
const body =
'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.';
const treatmentContent = {
title,
subtitle,
body,
first_cta_name: 'Create an account',
first_cta_link: 'https://profile.theguardian.com/signin',
second_cta_name: '', // empty string here makes the gate mandatory
};
const treatmentContentEncoded = JSON.stringify(treatmentContent);
return {
treatmentId: 'gandalf-mandatory-popup',
treatmentTrackingId: 'gandalf-mandatory-popup-tracking-id',
rank: '1',
contentLanguageCode: 'en-GB',
treatmentContent: treatmentContentEncoded,
treatmentType: 'NONDISMISSIBLE_SIGN_IN_GATE_POPUP',
surface: 'ARTICLE_PAGE',
};
};

export const isValidContentType = (contentType: string): boolean => {
const validTypes = ['Article'];
return validTypes.includes(contentType);
Expand All @@ -138,6 +180,7 @@ export const isValidSection = (sectionId: string): boolean => {
'guardian-live-australia',
'gnm-archive',
'thefilter',
'thefilter-us',
];
return !invalidSections.includes(sectionId);
};
Expand All @@ -148,6 +191,56 @@ export const isValidTagIds = (tagIds: string[]): boolean => {
return !tagIds.some((tagId: string): boolean => invalidTagIds.includes(tagId));
};

// --------------------------------------------------------------
// Gandalf (comment group: gandalf)
//
// "Gandalf" is the marketing name for the Guardian-managed sign-in gate
// journey: a 100% rollout, run entirely by Guardian rules with no Auxia
// involvement, currently live for New Zealand and switched on via the
// enableGandalfSignInGate channel switch.
//
// Gandalf widens the eligible content types while sharing the generic
// sign-in-gate display exclusions used by every country.

// The exact Guardian content metadata values (see DotcomContentType in
// guardian/frontend). Note that CAPI "Picture" pages are sent as ImageContent,
// and fronts are sent as Network Front / Section / Tag.
const gandalfContentTypes = [
'Article',
'Network Front',
'Section',
'Tag',
'Audio',
'Crossword',
'Gallery',
'Interactive',
'LiveBlog',
'ImageContent',
'Video',
];

export const gandalfIsValidContentType = (contentType: string): boolean => {
// Case-insensitive so casing drift upstream cannot silently exclude a page.
const validTypes = gandalfContentTypes.map((type) => type.toLowerCase());
return validTypes.includes(contentType.toLowerCase());
};

export const gandalfPageMetadataIsEligibleForGateDisplay = (
contentType: string,
sectionId: string,
tagIds: string[],
): boolean => {
return (
gandalfIsValidContentType(contentType) && isValidSection(sectionId) && isValidTagIds(tagIds)
);
};

// The free allowance: the first two pageviews of the day do not show a gate.
// The client sends dailyArticleCount, the number of pageviews the reader has
// already made today including the current one (1-based), so counts 1-2 are
// free and 3+ shows the hard popup.
export const GANDALF_FREE_PAGE_VIEW_COUNT = 2;

export const userTreatmentsEnvelopToProxyGetTreatmentsAnswerData = (
envelop: UserTreatmentsEnvelop,
): ProxyGetTreatmentsAnswerData | undefined => {
Expand All @@ -165,6 +258,12 @@ export const userTreatmentsEnvelopToProxyGetTreatmentsAnswerData = (
return {
responseId: envelop.responseId,
userTreatment: envelop.userTreatments[0],
// Only include the marker when set so responses for countries outside
// the Gandalf list keep their exact previous shape (the existing tests
// use toStrictEqual).
...(envelop.gandalfSignInGate !== undefined && {
gandalfSignInGate: envelop.gandalfSignInGate,
}),
};
};

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

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

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

// --------------------------------------------------------------
// Gandalf: the Guardian-managed sign-in gate journey
// (comment group: gandalf; "Gandalf" is the marketing name for this
// Guardian-owned, Auxia-free 100% rollout)
//
// Prerequisites:
// - the enableGandalfSignInGate channel switch is on (the journey is
// currently New Zealand only)
//
// Effects:
// - Guardian drives the gate, Auxia is never consulted (no GetTreatments
// and no LogTreatmentInteraction for either consent state)
// - the first two pageviews of the day are free (the response carries
// the gandalfSignInGate marker with no treatment, so the client knows
// this is a Guardian-managed decision and shows no gate)
// - from the third daily pageview onwards the Guardian-managed
// non-dismissible popup is returned
//
// The special cases below (URL denials, page eligibility, newsshowcase
// override and the staff testing feature) are deliberately evaluated with
// the generic display exclusions. Pages excluded here return 'None'
// without the marker.

if (enableGandalfSignInGate === true && payload.countryCode === 'NZ') {
if (!articleIdentifierIsAllowed(payload.articleIdentifier)) {
return 'None';
}
if (
!gandalfPageMetadataIsEligibleForGateDisplay(
payload.contentType,
payload.sectionId,
payload.tagIds,
)
) {
return 'None';
}
if (isOverridingConditionShowDismissibleGate(payload)) {
return 'GuDismissible';
}
if (isStaffTestConditionShowDefaultGate(payload)) {
return staffTestConditionToDefaultGate(payload);
}
return payload.dailyArticleCount <= GANDALF_FREE_PAGE_VIEW_COUNT
? 'GandalfFreeView'
: 'GandalfMandatoryPopup';
}

// --------------------------------------------------------------
// We do not show the gate on some specific article urls

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ it('articleIdentifierIsAllowed', () => {
expect(articleIdentifierIsAllowed('www.theguardian.com/tips')).toBe(false);
expect(articleIdentifierIsAllowed('www.theguardian.com/tips#test')).toBe(false);
expect(articleIdentifierIsAllowed('www.theguardian.com/tips/test')).toBe(false);
expect(articleIdentifierIsAllowed('www.theguardian.com/info/privacy')).toBe(false);
expect(articleIdentifierIsAllowed('www.theguardian.com/info/complaints-and-corrections')).toBe(
false,
);
expect(articleIdentifierIsAllowed('www.theguardian.com/the-whole-picture')).toBe(false);
});
Loading