@mindful-web/marko-web-identity-x 1.83.0 → 1.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +301 -7
- package/api/mutations/progressive-profile-update.js +29 -0
- package/browser/index.js +13 -0
- package/browser/progressive-profile.vue +388 -0
- package/components/access.marko.js +2 -2
- package/components/comment-stream.marko.js +2 -2
- package/components/context.marko.js +2 -2
- package/components/form-access.marko.js +2 -2
- package/components/form-authenticate.marko.js +2 -2
- package/components/form-change-email.marko.js +2 -2
- package/components/form-login.marko.js +2 -2
- package/components/form-logout.marko.js +2 -2
- package/components/form-profile.marko.js +2 -2
- package/components/form-register.marko.js +2 -2
- package/components/google-init.marko.js +2 -2
- package/components/google-sign-in-button.marko.js +2 -2
- package/components/identify.marko.js +2 -2
- package/components/marko.json +16 -0
- package/components/non-auth-identify.marko +6 -1
- package/components/non-auth-identify.marko.js +3 -3
- package/components/progressive-profile-form.marko +106 -0
- package/components/progressive-profile-form.marko.js +138 -0
- package/components/subscribe.marko.js +2 -2
- package/config.js +98 -0
- package/package.json +6 -5
- package/routes/google.js +5 -0
- package/routes/index.js +3 -0
- package/routes/progressive-profile.js +296 -0
- package/service.js +31 -0
- package/utils/add-custom-attributes.js +19 -46
- package/utils/build-progressive-profile-payload.js +117 -0
- package/utils/get-missing-progressive-fields.js +114 -0
- package/utils/get-progressive-form-fields.js +67 -0
- package/utils/progressive-profile-cookies.js +120 -0
- package/utils/progressive-profile-telemetry.js +148 -0
package/CLAUDE.md
CHANGED
|
@@ -99,16 +99,29 @@ path of authentication. To compare upstreams:
|
|
|
99
99
|
**`MindfulApiCall` is sampled.** Unsampled it ran ~55k events/hour / ~40M per month on the Fusable
|
|
100
100
|
fleet, 88.9% of it `GetActiveAppContext` + `CheckContentAccess` — per-page-load context calls that
|
|
101
101
|
are uniformly fast and analytically useless. Kept: **every non-`ok` outcome**, **every `ok` call
|
|
102
|
-
>= `MINDFUL_API_CALL_SLOW_MS`** (default 250ms
|
|
103
|
-
|
|
104
|
-
`MINDFUL_API_CALL_SAMPLE_RATE`** (default 100) of
|
|
105
|
-
number of real calls it represents, so `sum(sampleRate)` recovers true volume; `sampleRate = 1`
|
|
106
|
-
the call was kept because it failed or was slow.
|
|
102
|
+
>= `MINDFUL_API_CALL_SLOW_MS`** (default 250ms) *whose operation is not in*
|
|
103
|
+
**`MINDFUL_API_CALL_NO_SLOW_CAPTURE`** (default `GetActiveAppContext,CheckContentAccess`), and **1 in
|
|
104
|
+
`MINDFUL_API_CALL_SAMPLE_RATE`** (default 100) of everything else. Each event carries `sampleRate` =
|
|
105
|
+
the number of real calls it represents, so `sum(sampleRate)` recovers true volume; `sampleRate = 1`
|
|
106
|
+
means the call was kept because it failed or was slow.
|
|
107
|
+
|
|
108
|
+
**Why the exclusion list exists.** The 250ms threshold alone did not hold. It was tuned against a
|
|
109
|
+
`GetActiveAppContext` p95 of 206ms measured over only **1.4 hours**; over 48h that operation cleared
|
|
110
|
+
250ms constantly and produced **96.3% of all kept events** (259,448 of 269,322 — 5,625/hour), so the
|
|
111
|
+
sampler barely applied to the one operation generating the volume. Excluding the two per-page-load
|
|
112
|
+
context operations from *slow*-capture drops them through to the 1-in-N sampler instead. Raising the
|
|
113
|
+
threshold to ~1s was the alternative and is worse: it costs fidelity on every *other* operation to
|
|
114
|
+
fix two. Errors and timeouts are unaffected — `GetActiveAppContext` contributed 28 errors and the
|
|
115
|
+
only observed timeout (15,009ms against the 15s budget), and all of it still flows.
|
|
107
116
|
|
|
108
117
|
Consequences when querying: `average(ms)` over everything **skews high** (fast calls are
|
|
109
118
|
under-represented by design) — filter `WHERE sampleRate = 1` for the error/slow tail, or
|
|
110
|
-
`WHERE sampleRate > 1` for a fast-call view.
|
|
111
|
-
|
|
119
|
+
`WHERE sampleRate > 1` for a fast-call view. For the excluded operations the skew runs the *other*
|
|
120
|
+
way: every success is sampled regardless of duration, so their `sampleRate = 1` rows are errors and
|
|
121
|
+
timeouts **only**, never a slow tail — use `sampleRate > 1` with `percentile(ms, 95)` for their
|
|
122
|
+
latency. Set `MINDFUL_API_CALL_SAMPLE_RATE=1` to record everything again during an investigation, or
|
|
123
|
+
`MINDFUL_API_CALL_NO_SLOW_CAPTURE=` (explicitly empty) to restore slow-capture for every operation —
|
|
124
|
+
neither needs a deploy. An *unset* `NO_SLOW_CAPTURE` means the default list, not "exclude nothing".
|
|
112
125
|
|
|
113
126
|
It wraps `client.query`/`client.mutate`, **not** `fetch` — `@parameter1/omeda-graphql-client` sets
|
|
114
127
|
`fetch` *after* spreading the caller's `linkConfig`, so a wrapped transport cannot be injected from
|
|
@@ -310,6 +323,287 @@ after `setIdentity`. `google-auth-library` is now a base-idx dependency (every s
|
|
|
310
323
|
server-only) — accepted because the feature is gated on the client id. The Google OAuth app must
|
|
311
324
|
list each site origin as an authorized JavaScript origin or the surfaces silently no-show.
|
|
312
325
|
|
|
326
|
+
## Progressive Profiling — added 2026-08
|
|
327
|
+
|
|
328
|
+
Detects an **identified or authenticated** visitor with unanswered demographic attributes, gates
|
|
329
|
+
the article, and asks only the missing questions. The audience that matters is
|
|
330
|
+
*identified-but-not-authenticated* — the `__idx_idt` holder arriving from an email link, the
|
|
331
|
+
majority of who we know. Today those users' form answers go only to an anonymous content-access
|
|
332
|
+
submission and never touch their AppUser record (`form-access.marko:100-105` sets
|
|
333
|
+
`updateProfileOnSubmit = false`); this makes that write real.
|
|
334
|
+
|
|
335
|
+
**Dormant fleet-wide.** `progressiveProfileFieldRows` defaults to `[]`; nothing renders and no
|
|
336
|
+
shared behavior changes until a site declares fields *and* enables the theme's `progressiveProfile`
|
|
337
|
+
site config (Part 2/3, `marko-web-theme-monorail` + the website repos).
|
|
338
|
+
|
|
339
|
+
### The one hard constraint: attributes only
|
|
340
|
+
|
|
341
|
+
The backing mutation is `progressiveProfileUpdate` on **idx-compat**
|
|
342
|
+
(`mindful-reporting#263`, shipped 2026-08-05). It writes member **attributes** — select, boolean,
|
|
343
|
+
text — and nothing else; built-in/core member fields were cut on review. A built-in column would
|
|
344
|
+
render, accept a value, and then be silently unwritable: the user submits, gets `changed: false`,
|
|
345
|
+
and is re-gated forever. `config.js` therefore **throws at boot** on one, and on three other
|
|
346
|
+
configurations that are each a user-facing trap:
|
|
347
|
+
|
|
348
|
+
| Boot check | Why the runtime failure is worse than a failed deploy |
|
|
349
|
+
| --- | --- |
|
|
350
|
+
| `type: 'built-in'` | Unwritable ⇒ permanent gate loop. **Silent** — the only one of these with no visible symptom until a user is stuck. |
|
|
351
|
+
| `type: 'custom-boolean'` | `custom-column.vue` binds the checkbox to `customField.answer` but its handler writes `customField.value`, so a *required* checkbox can never be checked. The diff util, payload builder and mutation all handle booleans correctly — only the shared renderer does not. Re-allow once that is fixed. |
|
|
352
|
+
| any other type | Falls through to `custom-column.vue`'s `<pre v-else>` debug block, printing raw field metadata to a real visitor. |
|
|
353
|
+
| id missing from a non-empty `activeCustomFieldIds` | `routes/profile.js:88-104` silently discards the answer ⇒ gate loop. |
|
|
354
|
+
| rows configured with no `apiToken` | Every write uses `getOrgUserApiToken()` and the form token is signed from it. |
|
|
355
|
+
|
|
356
|
+
No coverage is lost: every field the fleet-wide impact analysis measured is a select attribute.
|
|
357
|
+
|
|
358
|
+
### Files
|
|
359
|
+
|
|
360
|
+
- `utils/get-missing-progressive-fields.js` — the diff. Matches on **`answer.field.id`**, never
|
|
361
|
+
`answer.id`. That distinction is the whole reason this feature does not reuse
|
|
362
|
+
`get-form-custom-fields.js` (or `config.getGTMUserData`): an idx answer's `id` is the *answer's*
|
|
363
|
+
id, so comparing it to a configured field id never matches, and the truthy-empty-array bug that
|
|
364
|
+
follows returns an answer object with no `field`. Returns `missingRows` with every column forced
|
|
365
|
+
`required: true`, so `custom-column.vue` needs no changes.
|
|
366
|
+
- `utils/get-progressive-form-fields.js` — correct, feature-scoped replacement for
|
|
367
|
+
`get-form-custom-fields.js`. Stamps `id` with the **field** id, which is the contract
|
|
368
|
+
`custom-column.vue` reads and what `get-create-user-custom-fields.js` already emits.
|
|
369
|
+
- `utils/build-progressive-profile-payload.js` — marshals the body into mutation variables, plus
|
|
370
|
+
`buildContentSource()`. **Definitions come from server config, never the body**, so a client
|
|
371
|
+
cannot widen what gets written by posting extra rows. Drops an empty select rather than sending
|
|
372
|
+
`optionIds: []`, which the writer reads as *unset* — the one way this feature could destroy data
|
|
373
|
+
despite the fill-blanks-only rule.
|
|
374
|
+
- `routes/progressive-profile.js` — `POST /__idx/progressive-profile` (+ `/dismiss`). A thin proxy;
|
|
375
|
+
it owns *trust*, not writes.
|
|
376
|
+
- `components/progressive-profile-form.marko` (`<marko-web-identity-x-progressive-profile-form>`)
|
|
377
|
+
+ `browser/progressive-profile.vue` (`IdentityXProgressiveProfile`, overridable via
|
|
378
|
+
`CustomProgressiveProfileComponent`).
|
|
379
|
+
- `utils/progressive-profile-telemetry.js` — resolves the New Relic agent (injected first, lazy
|
|
380
|
+
fallback) and exposes `createRecorder`, which emits one typed event per submit. See *Telemetry*.
|
|
381
|
+
- `utils/progressive-profile-cookies.js` — the attempts escape valve, the optional dismissal
|
|
382
|
+
suppression, and the 3-day suppression that follows an unverified submit. **Not** bot safeguards;
|
|
383
|
+
that is reCAPTCHA's job, below.
|
|
384
|
+
|
|
385
|
+
### What happens server-side of the route
|
|
386
|
+
|
|
387
|
+
Everything, in one Mongo transaction: the re-diff that drops already-filled fields, the attribute
|
|
388
|
+
writes, the `action-history` entries attributed via `context.source = 'PROGRESSIVE_PROFILE'`, and
|
|
389
|
+
one `member-events` document of kind `PROGRESSIVE_PROFILE_SUBMIT`. The route does no
|
|
390
|
+
fetch-then-write of its own, and **no session is ever minted** — a visitor stays merely identified,
|
|
391
|
+
so a bot cannot come away holding one. There is no `actionId` client-side; attribution lives in
|
|
392
|
+
`action-history`.
|
|
393
|
+
|
|
394
|
+
Auth picks the target and the client never does: an authenticated visitor writes as themselves
|
|
395
|
+
(`AppUser` token, `userId` ignored by the resolver), an identified one is written by the org token
|
|
396
|
+
with the **resolved** `targetId` — `body.userId` is never trusted and a mismatch is a 403.
|
|
397
|
+
|
|
398
|
+
Fires `onUserProfileUpdate` only when `changed`, guarded, reported through `onHookError`. That hook
|
|
399
|
+
is **provider-agnostic** — Omeda rapid-identify is the most visible consumer but one of five
|
|
400
|
+
(`marko-web-identity-x-mailchimp`, `marko-web-postup`, plus site-level Braze and ActiveCampaign
|
|
401
|
+
packages). The `profileUpdateHooks` field records `dispatched | skipped | error`, and `dispatched`
|
|
402
|
+
deliberately does **not** mean any hook succeeded: every registrant uses `shouldAwait: false`, and
|
|
403
|
+
`utils/call-hooks-for.js` wraps each promise in `.catch(onHookError)`, so `callHooksFor` returns
|
|
404
|
+
before the hooks run and essentially never rejects. An async failure is reported there and can never
|
|
405
|
+
reach this field; `error` is a *synchronous* dispatch throw only. Deliberately **not** `onLoginLinkSent` / `onAuthenticationSuccess`: those drive
|
|
406
|
+
auto-signup and the GA4 `subscribe` conversion, and this is not a registration.
|
|
407
|
+
|
|
408
|
+
### Bot protection — verify, then discard the write rather than the visitor
|
|
409
|
+
|
|
410
|
+
The threat is concrete: `marko-web-omeda-identity-x/middleware/set-identity-cookie.js` mints
|
|
411
|
+
`__idx_idt` for a **real person's** AppUser id on any request carrying `oly_enc_id`, so a newsletter
|
|
412
|
+
security scanner following an emailed link browses *as* an identified human and could write to a
|
|
413
|
+
real record. Cloudflare on these sites segregates rather than drops and scores 30–99 reach the
|
|
414
|
+
origin, so there is no upstream to lean on.
|
|
415
|
+
|
|
416
|
+
Verification is the fleet standard — **reCAPTCHA v3 via `@mindful-web/marko-web-recaptcha`**, the
|
|
417
|
+
same package, 0.5 floor and client shape as newsletter signup, contact-us, inquiries and the RigDig
|
|
418
|
+
checkout. Action `progressiveProfileSubmit`, declared client-side and checked server-side so a token
|
|
419
|
+
minted for another form cannot be replayed here.
|
|
420
|
+
|
|
421
|
+
**What differs is the response to a failure: the write is dropped, never the visitor.** The
|
|
422
|
+
submission "succeeds", the content unlocks, and the answers go nowhere. Every other form on the
|
|
423
|
+
fleet returns a hard 400 the visitor can do nothing about, which is right when the worst case is a
|
|
424
|
+
retried newsletter signup. Here the form is the only way past a gate on the article, so rejecting a
|
|
425
|
+
false positive locks a real reader out of content they can otherwise reach — a bigger loss than the
|
|
426
|
+
bot write it prevented. A bot gains an article it could largely have read anonymously, and gains no
|
|
427
|
+
write, which is the only thing actually at stake.
|
|
428
|
+
|
|
429
|
+
An earlier revision escalated to a reCAPTCHA v2 checkbox instead. Removed: it cost a second key pair
|
|
430
|
+
maintained fleet-wide plus a chunk of client code, to solve a problem **nobody has evidence exists**
|
|
431
|
+
— it is unknown whether newsletter scanners submit forms at all, or only load pages. Measure first
|
|
432
|
+
(below); the code is recoverable from `0f35bb1` if the data ever justifies it.
|
|
433
|
+
|
|
434
|
+
**Every failure mode passively accepts** — `low-score`, `no-token`, `bad-token`, `stale-token`,
|
|
435
|
+
`wrong-action` — each recorded separately. `no-token` matters as much as the rest: it is what an ad
|
|
436
|
+
blocker eating `api.js` produces, and the client deliberately **does not disable submit** when the
|
|
437
|
+
script fails to load (as the other forms do) because that would strand a reader behind a gate with
|
|
438
|
+
nothing to click.
|
|
439
|
+
|
|
440
|
+
**One rule for every visitor.** A failed check drops the write whether or not a session is present.
|
|
441
|
+
An earlier revision exempted session holders — `/user/profile` has no captcha either, so a session
|
|
442
|
+
is already sufficient authorization for a profile write elsewhere in this package — but that is a
|
|
443
|
+
user-initiated edit on a page someone navigated to, while this is an interstitial pushed at a
|
|
444
|
+
link-follower, which is exactly what an email scanner hits. Scanners follow magic links, so a
|
|
445
|
+
session is not proof of a human. The exemption also bought nothing: the score is recorded either
|
|
446
|
+
way, so authenticated submits still serve as the known-human control group.
|
|
447
|
+
|
|
448
|
+
**The suppression cookie is mandatory, not a nicety.** A discarded submit leaves the member's record
|
|
449
|
+
unchanged, so the diff would gate them again on the very next article — forever. `__idx_pp_sup`
|
|
450
|
+
(3 days, `progressiveProfile.unverifiedSuppressDays`) stops that and hands the visitor back to the
|
|
451
|
+
content meter, exactly as dismissal does. It is deliberately a **different cookie** from
|
|
452
|
+
`__idx_pp_dis`: "user skipped" and "we threw their answers away" need to be distinguishable in
|
|
453
|
+
Part 2's debug output and in support.
|
|
454
|
+
|
|
455
|
+
**The honest cost, and why the telemetry ships with this and not after.** A false-positive human
|
|
456
|
+
fills the form in good faith, nothing is saved, and three days later they are asked again —
|
|
457
|
+
permanently, invisibly to them *and to us*. The v2 challenge let them rescue themselves; this does
|
|
458
|
+
not. So the score is recorded on **every** submit, passing or not:
|
|
459
|
+
**one custom event per submit, typed by what happened** — `ProgressiveProfileSubmit` (the mutation
|
|
460
|
+
ran), `ProgressiveProfileDiscarded` (passive accept), `ProgressiveProfileRejected` (401/403/400,
|
|
461
|
+
carries `reason`) or `ProgressiveProfileError` (threw, or fell through unrecorded). Shared
|
|
462
|
+
attributes: `contentId`, `ms`, then `recaptcha`/`recaptchaScore` from the verify step and
|
|
463
|
+
`userId`/`auth` once the identity resolves; `Submit` adds `fieldCount`, `changed`,
|
|
464
|
+
`profileUpdateHooks`. Never the submitted answers.
|
|
465
|
+
|
|
466
|
+
Split by type rather than an `outcome` field so each outcome is independently countable and
|
|
467
|
+
alertable — "how many passive submits" is a bare `count(*)`, not a filter. There is deliberately no
|
|
468
|
+
`outcome`/`accepted` attribute; the type carries it, and two encodings drift. (The HTTP response
|
|
469
|
+
still returns `accepted` — that is the dataLayer's contract, unrelated.)
|
|
470
|
+
|
|
471
|
+
**The arithmetic is enforced, not left to discipline.** Each terminal branch records its own type,
|
|
472
|
+
`createRecorder` makes `record` a no-op once anything has fired, and a `finally` backstop catches a
|
|
473
|
+
path that recorded nothing — as `ProgressiveProfileError` with `reason: 'unrecorded'`, so a branch
|
|
474
|
+
added without a call site surfaces in New Relic instead of quietly skewing the mix.
|
|
475
|
+
|
|
476
|
+
**Why a custom event and not transaction attributes — and specifically not because of sampling.**
|
|
477
|
+
Measured before switching: these apps run 165–588 transactions/minute against a ~10k reservoir,
|
|
478
|
+
`Transaction` data is queryable 35 days back, and `idxGoogleStep` attributes land and query fine
|
|
479
|
+
today. Every New Relic destination is reservoir-backed anyway (`transaction_events`,
|
|
480
|
+
`custom_insights_events`, `application_logging.forwarding`), so there is no unsampled path to move
|
|
481
|
+
to; `recordLogEvent` would have been worst of all here, since every Log record on this account
|
|
482
|
+
arrives via the HTTP Log API with no `entity.guid` — no APM agent forwards application logs, so it
|
|
483
|
+
would likely have no-opped. The real reasons: the decision rule needs `histogram(recaptchaScore)`
|
|
484
|
+
and a dedicated event type is the right shape for it, `FROM Transaction WHERE name LIKE '%…%'` is a
|
|
485
|
+
string match a route rename silently breaks, and the measurement stops depending on `Transaction`
|
|
486
|
+
retention staying where someone set it. **Do not "fix" this back to `addCustomAttributes` on the
|
|
487
|
+
grounds that sampling was never a problem — it wasn't.**
|
|
488
|
+
|
|
489
|
+
**Deliberately unsampled**, unlike `MindfulApiCall`, which needed a hand-written sampler at ~40M
|
|
490
|
+
events/month. This is one row per gate submission: `count(*)` is the true count and there is no
|
|
491
|
+
`sampleRate` attribute.
|
|
492
|
+
|
|
493
|
+
The agent comes from `req.app.locals.newrelic` when a site passed one to `startServer`
|
|
494
|
+
(mindful-web#314), falling back to the shared lazy resolver in `@mindful-web/utils` — the option is
|
|
495
|
+
new and **no site passes it yet**, so without the fallback this would go dark fleet-wide. That
|
|
496
|
+
fallback is also why `utils/add-custom-attributes.js` is now a one-line delegation to
|
|
497
|
+
`newrelicAgent.addCustomAttributes`: it used to carry its own line-for-line copy of the same agent
|
|
498
|
+
resolution. `routes/google.js` is unchanged and keeps its step attributes on the transaction, where
|
|
499
|
+
`idxGoogleStep` riding on `TransactionError` is what makes a 500 self-describing.
|
|
500
|
+
|
|
501
|
+
Capturing the score required a small addition to the shared package: `verifyToken()` returns
|
|
502
|
+
`{ success, score, action, hostname, errorCodes }` instead of throwing, and `validateToken` is now a
|
|
503
|
+
thin policy layer over it with an unchanged contract. Reach for `verifyToken` when a caller needs to
|
|
504
|
+
*decide*; `validateToken` remains right for a form that should just refuse. **Two traps if you touch
|
|
505
|
+
it:** `science-medicine-group-websites`'s preference center echoes `e.message` verbatim to the
|
|
506
|
+
browser, so those three strings are user-facing copy; and `fusable-websites/packages/payfabric`
|
|
507
|
+
catches on `error.code`, which `http-errors` does not set, so a rejection there surfaces as a 500 —
|
|
508
|
+
adding a `.code` would silently flip it to 400. There is also a live regression trap inside
|
|
509
|
+
`validateToken` itself, commented at the call site: the original compared `undefined < minimumScore`
|
|
510
|
+
against the raw body, so a success carrying no score *passed*; `verifyToken` normalizes that to
|
|
511
|
+
`null`, and `null < 0.5` is true.
|
|
512
|
+
|
|
513
|
+
**The decision rule.** Run these after launch (`~/.newrelic/nrql`, account 2723345) and act on them;
|
|
514
|
+
a rejection rate is unactionable without the distribution behind it.
|
|
515
|
+
|
|
516
|
+
```sql
|
|
517
|
+
-- The mix. Rates span types with a comma-union, which NRQL supports natively.
|
|
518
|
+
SELECT count(*) FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded,
|
|
519
|
+
ProgressiveProfileRejected, ProgressiveProfileError FACET eventType() SINCE 1 day ago
|
|
520
|
+
|
|
521
|
+
-- Passive submits, and why.
|
|
522
|
+
SELECT count(*) FROM ProgressiveProfileDiscarded FACET recaptcha SINCE 1 day ago
|
|
523
|
+
|
|
524
|
+
-- The discard rate the rollout table keys on.
|
|
525
|
+
SELECT percentage(count(*), WHERE eventType() = 'ProgressiveProfileDiscarded')
|
|
526
|
+
FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded SINCE 1 week ago
|
|
527
|
+
|
|
528
|
+
SELECT histogram(recaptchaScore, 1.0, 10)
|
|
529
|
+
FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded SINCE 1 week ago
|
|
530
|
+
|
|
531
|
+
-- Authenticated submits are the known-human control group: if their p5 sits below the
|
|
532
|
+
-- threshold, the threshold is wrong, not the traffic.
|
|
533
|
+
SELECT percentile(recaptchaScore, 5, 25, 50)
|
|
534
|
+
FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded FACET auth SINCE 1 week ago
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
| Discard rate | Score shape | Action |
|
|
538
|
+
| --- | --- | --- |
|
|
539
|
+
| < 2% | any | Done. Passive accept is permanent. |
|
|
540
|
+
| 2–10% | clustered below ~0.2 | Bots. Keep passive accept. |
|
|
541
|
+
| 2–10% | clustered 0.3–0.5 | Lower `recaptchaMinimumScore`, re-measure. Far cheaper than a challenge. |
|
|
542
|
+
| > 10% | any | Build the v2 escalation — `0f35bb1`. |
|
|
543
|
+
|
|
544
|
+
**What actually bounds the damage** is not on this route and never was: the mutation re-diffs inside
|
|
545
|
+
its own transaction and can only ever fill a field that was already empty, so nothing that gets past
|
|
546
|
+
reCAPTCHA can destroy data. Every write also lands in `action-history` with its `input`/`result`, so
|
|
547
|
+
a bad one is findable and reversible from one collection.
|
|
548
|
+
|
|
549
|
+
**Escape valve.** `progressive-profile-cookies.js` tracks gate renders; the theme middleware
|
|
550
|
+
increments and releases the content past `maxAttemptsPerUser`, and this route clears the counter on
|
|
551
|
+
any success. The known cause of an unclearable gate is gone structurally, but a gate that can trap a
|
|
552
|
+
real person is worse than no gate.
|
|
553
|
+
|
|
554
|
+
### Telemetry & analytics
|
|
555
|
+
|
|
556
|
+
One custom event per submit, typed by outcome — `ProgressiveProfileSubmit` / `Discarded` /
|
|
557
|
+
`Rejected` / `Error`. Shared attributes `recaptcha`, `recaptchaScore`, `auth` (`org|user`; `FACET`
|
|
558
|
+
it to see how much traffic is identified-but-not-authenticated, the population the feature exists
|
|
559
|
+
for), `userId`, `contentId`, `ms`; `Submit` adds `changed`, `fieldCount`, `profileUpdateHooks`. See *Bot protection* above for why this is an event rather than transaction
|
|
560
|
+
attributes, and for the decision rule it feeds. Operational only — **submission counts come from
|
|
561
|
+
`member-events`.**
|
|
562
|
+
|
|
563
|
+
The one thing still on the transaction is `onHookError`'s payload
|
|
564
|
+
(`idxProgressiveProfileStep`, `idxUserId`), which rides on `noticeError` — a separate seam, and the
|
|
565
|
+
right place for it: a non-fatal hook failure belongs next to the error it describes.
|
|
566
|
+
|
|
567
|
+
The dataLayer bridge and the p1events bridge are independent subscribers to the same EventBus, so
|
|
568
|
+
what reaches each is decided separately:
|
|
569
|
+
|
|
570
|
+
- **p1events: form views only.** A dedicated branch in `marko-web-p1-events/browser/index.js`
|
|
571
|
+
(`Identity / View / Progressive Profile`, top-level content `entity`, `props` carrying
|
|
572
|
+
`audience`, `fieldCount`, `fieldKeys`). No submit event: `member-events` is written by the
|
|
573
|
+
mutation that did the work, inside its transaction, so no event means no write. Nothing is added
|
|
574
|
+
to `identifyOnSubmit` — the session is already identified before the form renders.
|
|
575
|
+
|
|
576
|
+
**Consequence of passive accept:** a discarded submit writes no `member-events` row, so the
|
|
577
|
+
abandonment funnel (view identities minus `PROGRESSIVE_PROFILE_SUBMIT`) counts it as abandonment.
|
|
578
|
+
Arguably correct — no data was collected — but "never submitted" and "submitted and we threw it
|
|
579
|
+
away" are different populations, and only the New Relic `unverified` count separates them.
|
|
580
|
+
- **GA4/dataLayer keeps a submit event**, carrying server-truth `changed` so the conversion trigger
|
|
581
|
+
can filter on `changed === true`. A discarded (unverified) submit reports `changed: false`, so it
|
|
582
|
+
is excluded from completions with **no GTM change**; the accompanying `accepted` value
|
|
583
|
+
(`written|unverified`) just makes discards separately countable. GA4 will read **lower** than
|
|
584
|
+
`member-events` (blockers, bot filters, unload races); the direction is always undercount.
|
|
585
|
+
Reconciling them is a watchlist item, not a bug.
|
|
586
|
+
|
|
587
|
+
**Trap:** `global-event-emitter.js` assigns `additionalEventData` *after* spreading `data`,
|
|
588
|
+
clobbering anything the caller passed under that key — which is why `access.vue`'s `ent:` never
|
|
589
|
+
reaches the bridge. The content entity is passed as `emit`'s third argument instead.
|
|
590
|
+
|
|
591
|
+
Submitted **answer values are never tracked** anywhere — `fieldKeys` and the member-event's
|
|
592
|
+
`fieldIds` record which questions were asked, nothing more.
|
|
593
|
+
|
|
594
|
+
### Two live follow-ups, neither in this package
|
|
595
|
+
|
|
596
|
+
1. 🔴 **Index drop/recreate, production.** `completed_auth_date_website_channel` and
|
|
597
|
+
`completed_auth_email_website_channel` had their `partialFilterExpression` widened; a changed
|
|
598
|
+
partial filter does not alter an index in place, so both must be dropped and recreated. Until
|
|
599
|
+
then progressive-profiling `member-events` queries collection-scan. Owner: audience/infra.
|
|
600
|
+
2. **The shared-code PR.** `get-form-custom-fields.js`, the commented-out `custom-boolean` branches
|
|
601
|
+
in `generate-required-field-payload.js`, the `routes/profile.js` allowlist, `custom-column.vue`'s
|
|
602
|
+
boolean binding, and the p1events bridge's `contentGatingType`/`contentGateType` mismatch (every
|
|
603
|
+
producer in the fleet sets the latter, so gate type has **never** reached p1events for any
|
|
604
|
+
gate). All shared with live flows across 18 repos — deliberately not fixed alongside a new
|
|
605
|
+
feature.
|
|
606
|
+
|
|
313
607
|
## Fleet / rollout
|
|
314
608
|
|
|
315
609
|
Not tracked in-repo. The Google-auth architecture change + fleet rollout plan lives outside the repo
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const gql = require('graphql-tag');
|
|
2
|
+
const userFragment = require('../fragments/active-user');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single consolidated write behind the progressive-profile gate.
|
|
6
|
+
*
|
|
7
|
+
* Everything happens server-side of this boundary, in one Mongo transaction: the re-diff that
|
|
8
|
+
* drops already-answered fields, the attribute writes, the `action-history` entries attributed to
|
|
9
|
+
* `PROGRESSIVE_PROFILE`, and the `PROGRESSIVE_PROFILE_SUBMIT` member-event that is the
|
|
10
|
+
* authoritative submission record. The route does no fetch-then-write of its own.
|
|
11
|
+
*
|
|
12
|
+
* Accepts either auth mode: an `AppUser` token writes to its own member and `userId` is ignored;
|
|
13
|
+
* an `OrgUserApiToken` writes to `input.userId`, which is the common case here — the gate's
|
|
14
|
+
* primary audience is identified-but-not-authenticated. **No session is ever minted**, so a
|
|
15
|
+
* visitor stays merely identified and a bot cannot come away holding one.
|
|
16
|
+
*/
|
|
17
|
+
module.exports = gql`
|
|
18
|
+
mutation ProgressiveProfileUpdate($input: ProgressiveProfileUpdateMutationInput!) {
|
|
19
|
+
progressiveProfileUpdate(input: $input) {
|
|
20
|
+
ok
|
|
21
|
+
changed
|
|
22
|
+
appUser {
|
|
23
|
+
...ActiveUserFragment
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
${userFragment}
|
|
29
|
+
`;
|
package/browser/index.js
CHANGED
|
@@ -9,6 +9,7 @@ const Download = () => import(/* webpackChunkName: "identity-x-download" */ './d
|
|
|
9
9
|
const Logout = () => import(/* webpackChunkName: "identity-x-logout" */ './logout.vue');
|
|
10
10
|
const Login = () => import(/* webpackChunkName: "identity-x-login" */ './login.vue');
|
|
11
11
|
const Profile = () => import(/* webpackChunkName: "identity-x-profile" */ './profile.vue');
|
|
12
|
+
const ProgressiveProfile = () => import(/* webpackChunkName: "identity-x-progressive-profile" */ './progressive-profile.vue');
|
|
12
13
|
const CommentStream = () => import(/* webpackChunkName: "identity-x-comment-stream" */ './comments/stream.vue');
|
|
13
14
|
|
|
14
15
|
const $graphql = createGraphqlClient({ uri: '/__graphql' });
|
|
@@ -25,6 +26,7 @@ export default (Browser, {
|
|
|
25
26
|
CustomLoginComponent,
|
|
26
27
|
CustomLogoutComponent,
|
|
27
28
|
CustomProfileComponent,
|
|
29
|
+
CustomProgressiveProfileComponent,
|
|
28
30
|
} = {}) => {
|
|
29
31
|
const AccessComponent = CustomAccessComponent || Access;
|
|
30
32
|
const AuthenticateComponent = CustomAuthenticateComponent || Authenticate;
|
|
@@ -35,6 +37,7 @@ export default (Browser, {
|
|
|
35
37
|
const LoginComponent = CustomLoginComponent || Login;
|
|
36
38
|
const LogoutComponent = CustomLogoutComponent || Logout;
|
|
37
39
|
const ProfileComponent = CustomProfileComponent || Profile;
|
|
40
|
+
const ProgressiveProfileComponent = CustomProgressiveProfileComponent || ProgressiveProfile;
|
|
38
41
|
|
|
39
42
|
window.IdentityX = new IdentityX();
|
|
40
43
|
|
|
@@ -48,6 +51,7 @@ export default (Browser, {
|
|
|
48
51
|
Browser.register('IdentityXLogin', LoginComponent, { provide: { EventBus } });
|
|
49
52
|
Browser.register('IdentityXLogout', LogoutComponent, { provide: { EventBus } });
|
|
50
53
|
Browser.register('IdentityXProfile', ProfileComponent, { provide: { EventBus } });
|
|
54
|
+
Browser.register('IdentityXProgressiveProfile', ProgressiveProfileComponent, { provide: { EventBus } });
|
|
51
55
|
|
|
52
56
|
// Ensure the client-side IdX context is refreshed when the authentication event occurs
|
|
53
57
|
EventBus.$on('identity-x-authenticated', () => window.IdentityX.refreshContext());
|
|
@@ -66,6 +70,7 @@ export default (Browser, {
|
|
|
66
70
|
'identity-x-login-mounted',
|
|
67
71
|
'identity-x-logout-mounted',
|
|
68
72
|
'identity-x-profile-mounted',
|
|
73
|
+
'identity-x-progressive-profile-mounted',
|
|
69
74
|
// Actions/submissions
|
|
70
75
|
'identity-x-access-submitted',
|
|
71
76
|
'identity-x-authenticated',
|
|
@@ -79,6 +84,12 @@ export default (Browser, {
|
|
|
79
84
|
'identity-x-login-link-sent',
|
|
80
85
|
'identity-x-logout',
|
|
81
86
|
'identity-x-profile-updated',
|
|
87
|
+
// GA4 keeps a submit conversion for progressive profiling even though p1events does not:
|
|
88
|
+
// campaign/source attribution is impossible from the server-side `member-events` record.
|
|
89
|
+
// The conversion trigger must filter on `changed === true` so a no-op submit is not counted
|
|
90
|
+
// as a profile completion. Expect GA4 to read LOWER than `member-events` -- blockers, bot
|
|
91
|
+
// filters and unload races drop dataLayer pushes but not server writes.
|
|
92
|
+
'identity-x-progressive-profile-submitted',
|
|
82
93
|
// Errors
|
|
83
94
|
'identity-x-access-errored',
|
|
84
95
|
'identity-x-authenticate-errored',
|
|
@@ -90,6 +101,8 @@ export default (Browser, {
|
|
|
90
101
|
'identity-x-login-errored',
|
|
91
102
|
'identity-x-logout-errored',
|
|
92
103
|
'identity-x-profile-errored',
|
|
104
|
+
'identity-x-progressive-profile-errored',
|
|
105
|
+
'identity-x-progressive-profile-dismissed',
|
|
93
106
|
].forEach((event) => {
|
|
94
107
|
EventBus.$on(event, (args) => {
|
|
95
108
|
if (!window.IdentityX) return;
|