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
27 changes: 25 additions & 2 deletions services/graphql/src/resolvers/customer.js
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,9 @@ module.exports = {
}

// Resolve the caller's stored encrypted ids (if any) to the one canonical, currently-active
// numeric customer id they agree on. Never throws: `null` means "fall back to email
// numeric customer id they agree on. Never throws: a null id means "fall back to email
// matching", which is exactly the behaviour every caller had before this field existed.
const resolvedCustomerId = await resolveOmedaCustomerId({
const { customerId: resolvedCustomerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds,
noticeError,
Expand Down Expand Up @@ -564,6 +564,29 @@ module.exports = {
]);
})(),
]);
/**
* Adoption telemetry. `matchedBy` is returned to the caller, but no client does anything with
* it -- so without this the only observable is `noticeError`, which counts *failures* with no
* denominator. Recording it here covers every caller and every hook path from one place.
*
* Three attributes, because one cannot answer the question. `omedaMatchedBy` alone conflates
* "the member had no stored id" with "the member's ids contradicted each other" -- the first
* is client rollout or a genuinely new member, the second is a duplicate pair needing an
* Omeda merge. `omedaIdResolution` separates them, and `omedaCandidateIdCount` gives the
* denominator for adoption.
*
* A `resolved` outcome alongside `matchedBy: 'email'` is meaningful on its own: it is the
* lookup-to-post race, where the id resolved cleanly and SCAO then rejected it.
*
* The agent no-ops when disabled or outside a transaction, so this is inert rather than
* fatal -- which matters on the blocking path of authentication.
*/
newrelic.addCustomAttributes({
omedaMatchedBy: matchedBy,
omedaIdResolution: outcome,
omedaCandidateIdCount: getAsArray(input, 'encryptedCustomerIds').length,
});

// `matchedBy` is resolver-provided, not an Omeda API value. Spreading preserves `CustomerId`,
// which the `RapidCustomerIdentification.customer` field resolver destructures.
return { ...response.data, matchedBy };
Expand Down
39 changes: 31 additions & 8 deletions services/graphql/src/utils/resolve-omeda-customer-id.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@
* @param {object} params.apiClient The Omeda API client.
* @param {string[]} [params.encryptedCustomerIds] Candidate encrypted ids for this customer.
* @param {function} params.noticeError Error reporter (New Relic's `noticeError`).
* @returns {Promise<number|null>} The agreed numeric customer id, or `null` to fall back to email.
* @returns {Promise<{ customerId: ?number, outcome: string }>} The agreed numeric customer id (or
* `null` to fall back to email), plus *why* — see `OUTCOMES`. The reason is returned rather than
* only reported, because "no id was stored" and "ids were stored but contradicted each other" are
* the same `null` here and completely different problems: the first is client rollout or a
* brand-new member, the second is a duplicate pair that needs merging in Omeda. Counting
* `noticeError` message strings to tell them apart would be fragile.
*/

/**
Expand All @@ -86,12 +91,27 @@ const MAX_CANDIDATES = 4;
/** Omeda encrypted customer ids are exactly this long; see the api client's attribute schema. */
const ENCRYPTED_ID_LENGTH = 15;

/**
* Why a resolution ended where it did. Faceting these separates the three populations that all
* look like "matched by email" from the outside: the client has nothing stored yet, the member is
* genuinely new, or the member's stored ids point at contradictory records.
*/
const OUTCOMES = {
NONE_SUPPLIED: 'none-supplied',
MALFORMED_ONLY: 'malformed-only',
TOO_MANY: 'too-many',
UNRESOLVABLE: 'unresolvable',
NONE_ACTIVE: 'none-active',
DIVERGED: 'diverged',
RESOLVED: 'resolved',
};

module.exports = async ({ apiClient, encryptedCustomerIds, noticeError } = {}) => {
const supplied = [...new Set((encryptedCustomerIds || [])
.filter((id) => id)
.map((id) => `${id}`.trim()))];
// Not an error: most callers have no stored id yet.
if (!supplied.length) return null;
if (!supplied.length) return { customerId: null, outcome: OUTCOMES.NONE_SUPPLIED };

// Malformed values can never name a customer, so they carry no claim about a write target and
// are dropped rather than allowed to veto a sibling. Mirrors the api client's own
Expand All @@ -101,11 +121,11 @@ module.exports = async ({ apiClient, encryptedCustomerIds, noticeError } = {}) =
if (candidates.length !== supplied.length) {
noticeError(new Error(`Ignoring ${supplied.length - candidates.length} malformed encrypted customer id(s): ${supplied.filter((id) => id.length !== ENCRYPTED_ID_LENGTH).join(', ')}.`));
}
if (!candidates.length) return null;
if (!candidates.length) return { customerId: null, outcome: OUTCOMES.MALFORMED_ONLY };

if (candidates.length > MAX_CANDIDATES) {
noticeError(new Error(`Refusing to resolve an Omeda customer from ${candidates.length} candidate encrypted ids (max ${MAX_CANDIDATES}). Falling back to email matching.`));
return null;
return { customerId: null, outcome: OUTCOMES.TOO_MANY };
}

const resource = apiClient.resource('customer');
Expand All @@ -130,7 +150,7 @@ module.exports = async ({ apiClient, encryptedCustomerIds, noticeError } = {}) =
// Refuse rather than let a sibling's answer stand in for it.
if (settled.some(({ state }) => state === 'unknown')) {
noticeError(new Error(`Could not resolve every candidate encrypted id (${candidates.join(', ')}); cannot rule out a second active customer. Falling back to email matching.`));
return null;
return { customerId: null, outcome: OUTCOMES.UNRESOLVABLE };
}

// Conclusively-dead ids are ignored, not disqualifying: a merged-away or unknown id alongside a
Expand All @@ -139,16 +159,19 @@ module.exports = async ({ apiClient, encryptedCustomerIds, noticeError } = {}) =

if (!resolved.length) {
noticeError(new Error(`Unable to resolve an Omeda customer from ${candidates.length} encrypted id(s): none are active. Falling back to email matching.`));
return null;
return { customerId: null, outcome: OUTCOMES.NONE_ACTIVE };
}

if (resolved.length > 1) {
// Two or more simultaneously-active customers for one member. Choosing would decide which
// record receives every future write, so refuse -- email matching continues as it does today.
// These are the pairs that need merging in Omeda; this is the signal that says which.
noticeError(new Error(`Omeda customer ids ${resolved.join(', ')} are all active for the same member (encrypted ids ${candidates.join(', ')}); cannot choose a write target. Falling back to email matching.`));
return null;
return { customerId: null, outcome: OUTCOMES.DIVERGED };
}

return resolved[0];
return { customerId: resolved[0], outcome: OUTCOMES.RESOLVED };
};

// Attached after the function assignment above, which would otherwise clobber it.
module.exports.OUTCOMES = OUTCOMES;
97 changes: 71 additions & 26 deletions services/graphql/test/utils/resolve-omeda-customer-id.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const { describe, it } = require('mocha');
const { expect } = require('chai');
const resolveOmedaCustomerId = require('../../src/utils/resolve-omeda-customer-id');

const { OUTCOMES } = resolveOmedaCustomerId;

const LIVE = '9130C2719701F5S';
const MERGED = '6466A3060334H6A';
const DEAD = '0240G4865912F6U';
Expand Down Expand Up @@ -41,13 +43,15 @@ describe('utils/resolve-omeda-customer-id', () => {
const { apiClient, calls } = clientWith({ [LIVE]: 1105483508 });
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [LIVE],
noticeError,
});

expect(id).to.equal(1105483508);
expect(customerId).to.equal(1105483508);

expect(outcome).to.equal(OUTCOMES.RESOLVED);
expect(errors).to.have.lengthOf(0);
// Merge-following must be requested, and a miss must not throw -- both are load-bearing.
expect(calls).to.deep.equal([{
Expand All @@ -63,13 +67,15 @@ describe('utils/resolve-omeda-customer-id', () => {
const { apiClient } = clientWith({ [MERGED]: 1100158437 });
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [MERGED],
noticeError,
});

expect(id).to.equal(1100158437);
expect(customerId).to.equal(1100158437);

expect(outcome).to.equal(OUTCOMES.RESOLVED);
expect(errors).to.have.lengthOf(0);
});

Expand All @@ -81,13 +87,15 @@ describe('utils/resolve-omeda-customer-id', () => {
const { apiClient, calls } = clientWith({ [MERGED]: 1100158437, [LIVE]: 1100158437 });
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [MERGED, LIVE],
noticeError,
});

expect(id).to.equal(1100158437);
expect(customerId).to.equal(1100158437);

expect(outcome).to.equal(OUTCOMES.RESOLVED);
expect(calls).to.have.lengthOf(2);
expect(errors).to.have.lengthOf(0);
});
Expand All @@ -97,13 +105,15 @@ describe('utils/resolve-omeda-customer-id', () => {
const { apiClient } = clientWith({ [DEAD]: null, [LIVE]: 1105483508 });
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [DEAD, LIVE],
noticeError,
});

expect(id).to.equal(1105483508);
expect(customerId).to.equal(1105483508);

expect(outcome).to.equal(OUTCOMES.RESOLVED);
expect(errors).to.have.lengthOf(0);
});

Expand All @@ -117,13 +127,15 @@ describe('utils/resolve-omeda-customer-id', () => {
});
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [LIVE, MERGED, DEAD],
noticeError,
});

expect(id).to.equal(1105483508);
expect(customerId).to.equal(1105483508);

expect(outcome).to.equal(OUTCOMES.RESOLVED);
expect(errors).to.have.lengthOf(0);
});

Expand All @@ -133,13 +145,15 @@ describe('utils/resolve-omeda-customer-id', () => {
const { apiClient, calls } = clientWith({ [LIVE]: 1105483508 });
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: ['too-short', LIVE],
noticeError,
});

expect(id).to.equal(1105483508);
expect(customerId).to.equal(1105483508);

expect(outcome).to.equal(OUTCOMES.RESOLVED);
expect(calls.map((c) => c.encryptedId)).to.deep.equal([LIVE]);
expect(errors).to.have.lengthOf(1);
expect(errors[0].message).to.contain('malformed');
Expand All @@ -155,13 +169,15 @@ describe('utils/resolve-omeda-customer-id', () => {
});
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [LIVE, OTHER],
noticeError,
});

expect(id).to.equal(null);
expect(customerId).to.equal(null);

expect(outcome).to.equal(OUTCOMES.UNRESOLVABLE);
expect(errors.map((e) => e.message).join(' ')).to.contain('socket hang up');
expect(errors.map((e) => e.message).join(' ')).to.contain('cannot rule out a second active customer');
});
Expand All @@ -170,13 +186,15 @@ describe('utils/resolve-omeda-customer-id', () => {
const { apiClient } = clientWith({ [LIVE]: new Error('socket hang up') });
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [LIVE],
noticeError,
});

expect(id).to.equal(null);
expect(customerId).to.equal(null);

expect(outcome).to.equal(OUTCOMES.UNRESOLVABLE);
expect(errors).to.have.lengthOf(2);
});

Expand All @@ -187,13 +205,15 @@ describe('utils/resolve-omeda-customer-id', () => {
const { apiClient } = clientWith({ [LIVE]: 1105483508, [OTHER]: 1108476082 });
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [LIVE, OTHER],
noticeError,
});

expect(id).to.equal(null);
expect(customerId).to.equal(null);

expect(outcome).to.equal(OUTCOMES.DIVERGED);
expect(errors).to.have.lengthOf(1);
expect(errors[0].message).to.contain('are all active for the same member');
// The report must name the customers, so the pairs needing an Omeda merge are identifiable.
Expand All @@ -205,13 +225,15 @@ describe('utils/resolve-omeda-customer-id', () => {
const { apiClient } = clientWith({ [DEAD]: null, [OTHER]: null });
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [DEAD, OTHER],
noticeError,
});

expect(id).to.equal(null);
expect(customerId).to.equal(null);

expect(outcome).to.equal(OUTCOMES.NONE_ACTIVE);
expect(errors).to.have.lengthOf(1);
expect(errors[0].message).to.contain('none are active');
});
Expand All @@ -220,13 +242,15 @@ describe('utils/resolve-omeda-customer-id', () => {
const { apiClient, calls } = clientWith({ [LIVE]: 1105483508 });
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [LIVE, LIVE, LIVE],
noticeError,
});

expect(id).to.equal(1105483508);
expect(customerId).to.equal(1105483508);

expect(outcome).to.equal(OUTCOMES.RESOLVED);
expect(calls).to.have.lengthOf(1);
expect(errors).to.have.lengthOf(0);
});
Expand All @@ -236,27 +260,48 @@ describe('utils/resolve-omeda-customer-id', () => {
const { apiClient, calls } = clientWith({});
const { errors, noticeError } = noticer();

const id = await resolveOmedaCustomerId({
const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: [LIVE, MERGED, DEAD, OTHER, '1234A5678901B2C'],
noticeError,
});

expect(id).to.equal(null);
expect(customerId).to.equal(null);

expect(outcome).to.equal(OUTCOMES.TOO_MANY);
expect(calls).to.have.lengthOf(0);
expect(errors).to.have.lengthOf(1);
expect(errors[0].message).to.contain('max 4');
});

it('reports malformed-only when every candidate is unusable', async () => {
// Distinct from `none-supplied`: the client DID store something, it just cannot name a
// customer. Faceting these apart is the point of returning an outcome at all.
const { apiClient, calls } = clientWith({});
const { errors, noticeError } = noticer();

const { customerId, outcome } = await resolveOmedaCustomerId({
apiClient,
encryptedCustomerIds: ['too-short', 'also-bad'],
noticeError,
});

expect(customerId).to.equal(null);
expect(outcome).to.equal(OUTCOMES.MALFORMED_ONLY);
expect(calls).to.have.lengthOf(0);
expect(errors).to.have.lengthOf(1);
});

it('returns null without an API call or an error report when no ids are supplied', async () => {
const { apiClient, calls } = clientWith({});
const { errors, noticeError } = noticer();

const ids = await Promise.all([undefined, null, [], ['', null]].map((encryptedCustomerIds) => (
const results = await Promise.all([undefined, null, [], ['', null]].map((encryptedCustomerIds) => (
resolveOmedaCustomerId({ apiClient, encryptedCustomerIds, noticeError })
)));

expect(ids).to.deep.equal([null, null, null, null]);
expect(results.map((r) => r.customerId)).to.deep.equal([null, null, null, null]);
expect(results.map((r) => r.outcome)).to.deep.equal(Array(4).fill(OUTCOMES.NONE_SUPPLIED));
expect(calls).to.have.lengthOf(0);
// Holding no stored id is the common case, not a failure -- it must not create error noise.
expect(errors).to.have.lengthOf(0);
Expand Down
Loading