@delmaredigital/payload-experiments 0.2.0-rc.6 → 0.3.0-rc.1

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.
Files changed (291) hide show
  1. package/README.md +129 -11
  2. package/dist/admin/PreviewLinks.d.ts +2 -1
  3. package/dist/admin/PreviewLinks.js +24 -16
  4. package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
  5. package/dist/admin/actions/ConfirmDialog.js +37 -82
  6. package/dist/admin/actions/DecideDialog.d.ts +3 -1
  7. package/dist/admin/actions/DecideDialog.js +30 -13
  8. package/dist/admin/actions/MakeContentResult.d.ts +13 -2
  9. package/dist/admin/actions/MakeContentResult.js +115 -16
  10. package/dist/admin/actions/actions.model.d.ts +2 -4
  11. package/dist/admin/actions/actions.model.js +2 -3
  12. package/dist/admin/allocation.model.js +3 -5
  13. package/dist/admin/api.d.ts +10 -4
  14. package/dist/admin/api.js +11 -7
  15. package/dist/admin/copy/actions.d.ts +39 -3
  16. package/dist/admin/copy/actions.js +40 -4
  17. package/dist/admin/copy/armSelect.js +5 -5
  18. package/dist/admin/copy/arms.d.ts +1 -7
  19. package/dist/admin/copy/arms.js +1 -7
  20. package/dist/admin/copy/audience.d.ts +2 -1
  21. package/dist/admin/copy/audience.js +3 -2
  22. package/dist/admin/copy/common.d.ts +13 -6
  23. package/dist/admin/copy/common.js +14 -8
  24. package/dist/admin/copy/goals.d.ts +21 -6
  25. package/dist/admin/copy/goals.js +27 -6
  26. package/dist/admin/copy/readiness.d.ts +19 -0
  27. package/dist/admin/copy/readiness.js +19 -0
  28. package/dist/admin/copy/recommendation.d.ts +0 -3
  29. package/dist/admin/copy/recommendation.js +0 -3
  30. package/dist/admin/copy/results.d.ts +41 -2
  31. package/dist/admin/copy/results.js +42 -3
  32. package/dist/admin/copy/review.d.ts +1 -1
  33. package/dist/admin/copy/review.js +1 -1
  34. package/dist/admin/copy/setting.d.ts +2 -0
  35. package/dist/admin/copy/setting.js +2 -0
  36. package/dist/admin/copy/surface.d.ts +7 -4
  37. package/dist/admin/copy/surface.js +9 -4
  38. package/dist/admin/copy/testPage.d.ts +12 -0
  39. package/dist/admin/copy/testPage.js +12 -0
  40. package/dist/admin/format/date.d.ts +2 -0
  41. package/dist/admin/format/date.js +10 -0
  42. package/dist/admin/labels.d.ts +2 -7
  43. package/dist/admin/labels.js +2 -7
  44. package/dist/admin/previewLinks.model.d.ts +2 -2
  45. package/dist/admin/previewLinks.model.js +5 -7
  46. package/dist/admin/results/CiChart.js +11 -21
  47. package/dist/admin/results/DataQualityPanel.d.ts +4 -2
  48. package/dist/admin/results/DataQualityPanel.js +30 -17
  49. package/dist/admin/results/DataTable.d.ts +16 -0
  50. package/dist/admin/results/DataTable.js +152 -0
  51. package/dist/admin/results/StatTile.d.ts +7 -0
  52. package/dist/admin/results/StatTile.js +22 -0
  53. package/dist/admin/results/VerdictHero.d.ts +13 -0
  54. package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
  55. package/dist/admin/results/releasePages.model.d.ts +17 -0
  56. package/dist/admin/results/releasePages.model.js +10 -0
  57. package/dist/admin/results/results.css +64 -0
  58. package/dist/admin/results/results.model.d.ts +6 -2
  59. package/dist/admin/results/results.model.js +12 -5
  60. package/dist/admin/surface/ResultsRoute.d.ts +1 -1
  61. package/dist/admin/surface/ResultsRoute.js +60 -31
  62. package/dist/admin/surface/SurfaceShell.d.ts +11 -2
  63. package/dist/admin/surface/SurfaceShell.js +49 -14
  64. package/dist/admin/surface/TestsHome.d.ts +6 -1
  65. package/dist/admin/surface/TestsHome.js +183 -112
  66. package/dist/admin/surface/TestsSurface.js +56 -16
  67. package/dist/admin/surface/contracts.d.ts +1 -0
  68. package/dist/admin/surface/goals/GoalForm.js +341 -308
  69. package/dist/admin/surface/goals/GoalsList.js +123 -89
  70. package/dist/admin/surface/goals/data.d.ts +8 -2
  71. package/dist/admin/surface/goals/data.js +11 -3
  72. package/dist/admin/surface/goals/goals.css +10 -13
  73. package/dist/admin/surface/home.model.d.ts +7 -8
  74. package/dist/admin/surface/home.model.js +23 -12
  75. package/dist/admin/surface/newTest/NewTest.js +5 -5
  76. package/dist/admin/surface/pageBlock/parts.js +31 -26
  77. package/dist/admin/surface/readiness/ActionDialogs.js +43 -14
  78. package/dist/admin/surface/readiness/Banner.d.ts +3 -0
  79. package/dist/admin/surface/readiness/Banner.js +63 -36
  80. package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
  81. package/dist/admin/surface/readiness/SaveStatus.js +6 -3
  82. package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
  83. package/dist/admin/surface/readiness/readiness.model.js +18 -9
  84. package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
  85. package/dist/admin/surface/readiness/useTransition.js +2 -1
  86. package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
  87. package/dist/admin/surface/results/ReleasePages.js +116 -0
  88. package/dist/admin/surface/results/ResultsBody.d.ts +15 -2
  89. package/dist/admin/surface/results/ResultsBody.js +281 -107
  90. package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
  91. package/dist/admin/surface/results/ResultsHeader.js +177 -0
  92. package/dist/admin/surface/results/recommendation.model.js +2 -6
  93. package/dist/admin/surface/results/useResults.d.ts +21 -3
  94. package/dist/admin/surface/results/useResults.js +2 -1
  95. package/dist/admin/surface/setting/parts.js +75 -88
  96. package/dist/admin/surface/setting/setting.css +3 -15
  97. package/dist/admin/surface/surface.css +62 -47
  98. package/dist/admin/surface/test/AdvancedSection.js +55 -48
  99. package/dist/admin/surface/test/GoalSection.js +39 -32
  100. package/dist/admin/surface/test/LengthSection.js +46 -34
  101. package/dist/admin/surface/test/Rail.d.ts +4 -0
  102. package/dist/admin/surface/test/Rail.js +83 -0
  103. package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
  104. package/dist/admin/surface/test/ReadinessBar.js +33 -0
  105. package/dist/admin/surface/test/Section.d.ts +1 -1
  106. package/dist/admin/surface/test/Section.js +36 -16
  107. package/dist/admin/surface/test/TargetingSection.js +168 -142
  108. package/dist/admin/surface/test/TestPage.d.ts +2 -1
  109. package/dist/admin/surface/test/TestPage.js +146 -59
  110. package/dist/admin/surface/test/VersionsSection.js +151 -120
  111. package/dist/admin/surface/test/fields.d.ts +11 -4
  112. package/dist/admin/surface/test/fields.js +84 -85
  113. package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
  114. package/dist/admin/surface/test/jumpToSection.js +10 -0
  115. package/dist/admin/surface/test/pageParts.js +9 -3
  116. package/dist/admin/surface/test/testPage.css +81 -14
  117. package/dist/admin/surface/useTestDocument.d.ts +1 -0
  118. package/dist/admin/surface/useTestDocument.js +40 -9
  119. package/dist/admin/targeting/targeting.model.d.ts +1 -3
  120. package/dist/admin/targeting/targeting.model.js +3 -8
  121. package/dist/admin/theme.css +115 -36
  122. package/dist/admin/theme.d.ts +38 -0
  123. package/dist/admin/theme.js +38 -1
  124. package/dist/admin/useApi.d.ts +17 -2
  125. package/dist/admin/views/ExperimentsRootView.js +12 -2
  126. package/dist/collections/experiments/config.js +44 -1
  127. package/dist/collections/experiments/lockHook.js +16 -2
  128. package/dist/collections/identities.js +6 -1
  129. package/dist/collections/metrics.js +7 -0
  130. package/dist/collections/metricsGuards.d.ts +9 -0
  131. package/dist/collections/metricsGuards.js +33 -0
  132. package/dist/constants.d.ts +1 -0
  133. package/dist/constants.js +1 -0
  134. package/dist/contentDone.d.ts +4 -0
  135. package/dist/contentDone.js +5 -0
  136. package/dist/db/identities.d.ts +8 -7
  137. package/dist/db/identities.js +9 -6
  138. package/dist/db/metricUsage.d.ts +19 -0
  139. package/dist/db/metricUsage.js +54 -0
  140. package/dist/db/names.d.ts +5 -0
  141. package/dist/db/names.js +5 -0
  142. package/dist/db/publishedContent.d.ts +10 -0
  143. package/dist/db/publishedContent.js +43 -0
  144. package/dist/db/results.d.ts +2 -0
  145. package/dist/db/results.js +6 -2
  146. package/dist/db/snapshots.d.ts +18 -6
  147. package/dist/db/snapshots.js +23 -7
  148. package/dist/db/staleEdit.d.ts +6 -0
  149. package/dist/db/staleEdit.js +10 -0
  150. package/dist/db/sweep.js +11 -6
  151. package/dist/endpoints/events.js +3 -3
  152. package/dist/endpoints/index.js +19 -1
  153. package/dist/endpoints/recompute.js +2 -1
  154. package/dist/endpoints/results.js +5 -0
  155. package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
  156. package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
  157. package/dist/endpoints/summary.js +20 -2
  158. package/dist/endpoints/transitions.d.ts +15 -0
  159. package/dist/endpoints/transitions.js +36 -0
  160. package/dist/errors.d.ts +8 -0
  161. package/dist/errors.js +28 -0
  162. package/dist/exports/adminClient.d.ts +1 -0
  163. package/dist/exports/adminClient.js +1 -0
  164. package/dist/exports/index.d.ts +2 -1
  165. package/dist/exports/index.js +1 -0
  166. package/dist/exports/puckEditor.d.ts +1 -0
  167. package/dist/exports/puckEditor.js +1 -0
  168. package/dist/exports/rsc.d.ts +5 -1
  169. package/dist/exports/rsc.js +2 -0
  170. package/dist/hooks/hostHook.js +3 -2
  171. package/dist/hooks/pageGuard.d.ts +5 -0
  172. package/dist/hooks/pageGuard.js +121 -0
  173. package/dist/labels/arms.d.ts +7 -0
  174. package/dist/labels/arms.js +7 -0
  175. package/dist/log.d.ts +7 -0
  176. package/dist/log.js +38 -0
  177. package/dist/manifest/compile.d.ts +5 -0
  178. package/dist/manifest/compile.js +5 -1
  179. package/dist/options.js +23 -0
  180. package/dist/plugin.js +26 -2
  181. package/dist/puck/copy.d.ts +1 -1
  182. package/dist/puck/copy.js +6 -6
  183. package/dist/puck/editor/editor.chrome.css +235 -0
  184. package/dist/puck/editor/editor.css +241 -43
  185. package/dist/results/aggregate.js +4 -3
  186. package/dist/results/analyse.d.ts +4 -1
  187. package/dist/results/analyse.js +27 -11
  188. package/dist/results/cuped.js +3 -2
  189. package/dist/results/identityCredit.d.ts +15 -7
  190. package/dist/results/identityCredit.js +32 -33
  191. package/dist/results/rolloutUnits.js +2 -1
  192. package/dist/results/snapshot.js +8 -2
  193. package/dist/results/srm.js +2 -1
  194. package/dist/results/verdict.d.ts +2 -0
  195. package/dist/results/verdict.js +18 -1
  196. package/dist/sanitizeReadinessDetail.d.ts +7 -0
  197. package/dist/sanitizeReadinessDetail.js +54 -0
  198. package/dist/server/linkIdentity.d.ts +10 -1
  199. package/dist/server/linkIdentity.js +20 -18
  200. package/dist/server/metadata.d.ts +5 -0
  201. package/dist/server/metadata.js +9 -1
  202. package/dist/server/recordContentChange.js +2 -1
  203. package/dist/server/recordExposures.js +3 -3
  204. package/dist/server/runResults.d.ts +3 -2
  205. package/dist/server/runResults.js +125 -34
  206. package/dist/server/trackConversion.js +5 -6
  207. package/dist/targeting/values.d.ts +4 -0
  208. package/dist/targeting/values.js +10 -0
  209. package/dist/transitions/addMetric.js +8 -0
  210. package/dist/transitions/archive.js +2 -3
  211. package/dist/transitions/checkStart.d.ts +6 -3
  212. package/dist/transitions/checkStart.js +15 -0
  213. package/dist/transitions/contentFields.d.ts +7 -0
  214. package/dist/transitions/contentFields.js +20 -0
  215. package/dist/transitions/decide.js +6 -1
  216. package/dist/transitions/deleteExperiment.js +2 -1
  217. package/dist/transitions/dismissContentEntry.d.ts +8 -0
  218. package/dist/transitions/dismissContentEntry.js +45 -0
  219. package/dist/transitions/makeContent.d.ts +5 -2
  220. package/dist/transitions/makeContent.js +217 -118
  221. package/dist/transitions/phaseRecord.d.ts +8 -2
  222. package/dist/transitions/placements.d.ts +1 -0
  223. package/dist/transitions/placements.js +4 -0
  224. package/dist/transitions/preflightData.d.ts +1 -1
  225. package/dist/transitions/preflightData.js +42 -4
  226. package/dist/transitions/releasePages.d.ts +8 -0
  227. package/dist/transitions/releasePages.js +26 -0
  228. package/dist/transitions/restoreContentEntry.d.ts +8 -0
  229. package/dist/transitions/restoreContentEntry.js +47 -0
  230. package/dist/transitions/start.js +30 -5
  231. package/dist/types/contracts.d.ts +14 -0
  232. package/dist/types/endpoints.d.ts +34 -3
  233. package/dist/types/index.d.ts +1 -0
  234. package/dist/types/index.js +1 -0
  235. package/dist/types/reasons.d.ts +3 -0
  236. package/dist/types/reasons.js +1 -0
  237. package/dist/types/snapshot.d.ts +6 -1
  238. package/dist/ui/Banner.d.ts +2 -0
  239. package/dist/ui/Banner.js +2 -0
  240. package/dist/ui/Breadcrumb.d.ts +7 -0
  241. package/dist/ui/Breadcrumb.js +25 -0
  242. package/dist/ui/Button.d.ts +15 -0
  243. package/dist/ui/Button.js +32 -0
  244. package/dist/ui/Card.d.ts +12 -0
  245. package/dist/ui/Card.js +40 -0
  246. package/dist/ui/Checkbox.d.ts +2 -0
  247. package/dist/ui/Checkbox.js +2 -0
  248. package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
  249. package/dist/{admin/components → ui}/ChipsInput.js +20 -12
  250. package/dist/ui/Dialog.d.ts +13 -0
  251. package/dist/ui/Dialog.js +85 -0
  252. package/dist/ui/EmptyState.d.ts +2 -0
  253. package/dist/ui/EmptyState.js +2 -0
  254. package/dist/ui/Feedback.d.ts +24 -0
  255. package/dist/ui/Feedback.js +40 -0
  256. package/dist/ui/Inputs.d.ts +34 -0
  257. package/dist/ui/Inputs.js +225 -0
  258. package/dist/ui/NumberInput.d.ts +2 -0
  259. package/dist/ui/NumberInput.js +2 -0
  260. package/dist/ui/Range.d.ts +2 -0
  261. package/dist/ui/Range.js +2 -0
  262. package/dist/ui/SearchInput.d.ts +2 -0
  263. package/dist/ui/SearchInput.js +2 -0
  264. package/dist/ui/SectionCard.d.ts +2 -0
  265. package/dist/ui/SectionCard.js +2 -0
  266. package/dist/ui/Select.d.ts +2 -0
  267. package/dist/ui/Select.js +2 -0
  268. package/dist/ui/StatusChip.d.ts +2 -0
  269. package/dist/ui/StatusChip.js +2 -0
  270. package/dist/ui/Tabs.d.ts +14 -0
  271. package/dist/ui/Tabs.js +54 -0
  272. package/dist/ui/TextInput.d.ts +2 -0
  273. package/dist/ui/TextInput.js +2 -0
  274. package/dist/ui/Textarea.d.ts +2 -0
  275. package/dist/ui/Textarea.js +2 -0
  276. package/dist/ui/Toggle.d.ts +2 -0
  277. package/dist/ui/Toggle.js +2 -0
  278. package/dist/ui/accent.d.ts +21 -0
  279. package/dist/ui/accent.js +81 -0
  280. package/dist/ui/index.d.ts +9 -0
  281. package/dist/ui/index.js +9 -0
  282. package/dist/ui/ui.css +92 -0
  283. package/package.json +1 -1
  284. package/dist/admin/components/chipsInput.css +0 -11
  285. package/dist/admin/results/ArmTable.d.ts +0 -8
  286. package/dist/admin/results/ArmTable.js +0 -113
  287. package/dist/admin/results/VerdictCard.d.ts +0 -9
  288. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
  289. package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
  290. package/dist/admin/surface/test/TestHeader.d.ts +0 -4
  291. package/dist/admin/surface/test/TestHeader.js +0 -69
@@ -1,14 +1,15 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  /** Accept Date, timestamp text, or epoch milliseconds; reject invalid dates. */ export function timestampMs(ts) {
2
3
  if (!(ts instanceof Date) && typeof ts !== 'string' && typeof ts !== 'number') {
3
- throw new RangeError('Invalid timestamp');
4
+ throw new XpAnalysisError('invalid-timestamp');
4
5
  }
5
6
  const ms = ts instanceof Date ? ts.getTime() : new Date(ts).getTime();
6
- if (!Number.isFinite(ms)) throw new RangeError('Invalid timestamp');
7
+ if (!Number.isFinite(ms)) throw new XpAnalysisError('invalid-timestamp');
7
8
  return ms;
8
9
  }
9
10
  /** Attribution windows must have a finite, positive length in hours. */ export function windowMs(windowHours) {
10
11
  if (!Number.isFinite(windowHours) || windowHours <= 0) {
11
- throw new RangeError('windowHours must be a finite number greater than 0');
12
+ throw new XpAnalysisError('window-hours');
12
13
  }
13
14
  return windowHours * 3_600_000;
14
15
  }
@@ -1,4 +1,5 @@
1
1
  import type { GaussianPrior } from '@delmaredigital/experiments-stats';
2
+ import type { MetricUnavailableReason } from '../types/reasons.js';
2
3
  import type { Arm, DocId, MetricSource } from '../types/contracts.js';
3
4
  import type { Snapshot } from '../types/snapshot.js';
4
5
  import type { ExposedUnit, Timestamp } from './aggregate.js';
@@ -55,10 +56,12 @@ export interface MetricUnitData {
55
56
  values: ReadonlyMap<string, number>;
56
57
  /** Host pre-period outcomes; absent entries in a supplied map mean zero. */
57
58
  covariates?: ReadonlyMap<string, number>;
58
- unavailable?: string;
59
+ unavailable?: MetricUnavailableReason;
59
60
  }
60
61
  export type AnalysePhaseInput = {
61
62
  phase: AnalysisPhase;
63
+ /** The runner logs failures; injecting this keeps analysis free of I/O imports. */
64
+ onMetricError?: (error: unknown, metricId: DocId) => void;
62
65
  status: 'running' | 'stopped' | 'decided';
63
66
  now: Timestamp;
64
67
  /** CORE-14a firstExposures output; same-unit multi-arm units already excluded. */
@@ -1,3 +1,4 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  import { DEFAULT_GAUSSIAN_PRIOR, bonferroniAlpha, capValues, createThetaAdjustedStatistics, effectBayesianABTest, sequentialOneSidedTreatmentGreaterTTest, sequentialOneSidedTreatmentLesserTTest, sequentialTwoSidedTTest, statisticMean, statisticVariance } from '@delmaredigital/experiments-stats';
2
3
  import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
3
4
  import { timestampMs } from './aggregate.js';
@@ -18,8 +19,8 @@ const severity = {
18
19
  */ export function analysePhase(input) {
19
20
  const { phase, units } = input;
20
21
  const { analysis } = phase;
21
- // Start freezes this optional collection field as-is. Match the stats engine's
22
- // default and record the effective value in every snapshot, including n = 0.
22
+ // Compatibility for phases created before Start froze the effective tuning
23
+ // value. Record that effective value in every snapshot, including n = 0.
23
24
  const sequentialTuningParameter = analysis.sequentialTuningParameter ?? DEFAULT_SEQUENTIAL_TUNING_PARAMETER;
24
25
  const alpha = analysis.alpha ?? 0.05;
25
26
  const prior = analysis.prior ?? DEFAULT_GAUSSIAN_PRIOR;
@@ -30,7 +31,7 @@ const severity = {
30
31
  const rollout = phase.phaseKind === 'rollout';
31
32
  const baselineArm = phase.arms.find((arm)=>arm.kind === (rollout ? 'holdout' : 'control'));
32
33
  const winner = rollout ? phase.arms.find((arm)=>arm.key === input.winnerArm && arm.kind !== 'holdout') : undefined;
33
- if (!baselineArm || rollout && (!winner || phase.arms.length !== 2)) throw new RangeError('Phase comparison baseline or shipped arm is missing');
34
+ if (!baselineArm || rollout && (!winner || phase.arms.length !== 2)) throw new XpAnalysisError('no-baseline');
34
35
  const compared = phase.arms.filter((arm)=>rollout ? arm.key === winner.key : arm.kind === 'variant');
35
36
  const k = compared.length;
36
37
  const alphaAdjusted = analysis.method === 'sequential' ? bonferroniAlpha(alpha, k) : alpha;
@@ -42,22 +43,22 @@ const severity = {
42
43
  }));
43
44
  const primaryDefinition = definitions.find((metric)=>metric.role === 'primary');
44
45
  if (!primaryDefinition || phase.metricDefinitions.filter((metric)=>metric.role === 'primary').length !== 1) {
45
- throw new RangeError('Phase must have exactly one primary metric');
46
+ throw new XpAnalysisError('primary-metric-count');
46
47
  }
47
48
  if (new Set(phase.metricDefinitions.map((metric)=>String(metric.metricId))).size !== phase.metricDefinitions.length || new Set(input.metricData.map((metric)=>String(metric.metricId))).size !== input.metricData.length) {
48
- throw new RangeError('Duplicate metric id');
49
+ throw new XpAnalysisError('duplicate-metric');
49
50
  }
50
51
  const from = timestampMs(phase.startedAt);
51
52
  const now = timestampMs(input.now);
52
53
  const to = Math.min(now, phase.endedAt == null ? now : timestampMs(phase.endedAt));
53
- if (to < from) throw new RangeError('Analysis window ends before the phase starts');
54
+ if (to < from) throw new XpAnalysisError('window-before-start');
54
55
  const byArm = new Map(phase.arms.map((arm)=>[
55
56
  arm.key,
56
57
  []
57
58
  ]));
58
59
  for (const [id, unit] of units){
59
60
  const ids = byArm.get(unit.arm);
60
- if (!ids) throw new RangeError('Exposed unit has an unknown arm');
61
+ if (!ids) throw new XpAnalysisError('unknown-arm');
61
62
  ids.push(id);
62
63
  }
63
64
  const perArm = Object.fromEntries([
@@ -120,17 +121,19 @@ const severity = {
120
121
  let worstArmLabel = compared[0]?.label ?? '';
121
122
  let worstArm = compared[0]?.key ?? '';
122
123
  try {
123
- if (!data || data.unavailable !== undefined) throw new Error(data?.unavailable ?? 'Metric data was not supplied');
124
+ if (!data || data.unavailable !== undefined) {
125
+ throw new XpAnalysisError('metric-not-supplied');
126
+ }
124
127
  const stats = new Map();
125
128
  const cap = kind === 'revenue' ? definition.resolvedCap ?? null : null;
126
129
  const useCovariates = analysis.cuped === 'auto' && data.covariates !== undefined && data.covariates.size > 0;
127
130
  for (const arm of phase.arms){
128
131
  const ids = byArm.get(arm.key);
129
132
  const values = ids.map((id)=>data.values.get(id) ?? 0);
130
- if (!values.every(Number.isFinite)) throw new RangeError('Metric values must be finite');
133
+ if (!values.every(Number.isFinite)) throw new XpAnalysisError('non-finite-values');
131
134
  const capped = capValues(values, cap);
132
135
  const rawPre = useCovariates ? ids.map((id)=>data.covariates.get(id) ?? 0) : undefined;
133
- if (rawPre && !rawPre.every(Number.isFinite)) throw new RangeError('Covariates must be finite');
136
+ if (rawPre && !rawPre.every(Number.isFinite)) throw new XpAnalysisError('non-finite-covariates');
134
137
  const pre = rawPre && capValues(rawPre, cap).values;
135
138
  const built = outcomeStatistic(kind, capped.values, pre);
136
139
  stats.set(arm.key, built.statistic);
@@ -257,7 +260,20 @@ const severity = {
257
260
  }
258
261
  }
259
262
  } catch (error) {
260
- metric.unavailable = error instanceof Error ? error.message : 'Metric could not be analysed';
263
+ input.onMetricError?.(error, metricId);
264
+ let reason = 'analysis-failed';
265
+ if (error instanceof XpAnalysisError) {
266
+ switch(error.code){
267
+ case 'metric-not-supplied':
268
+ reason = data?.unavailable ?? 'not-supplied';
269
+ break;
270
+ case 'non-finite-values':
271
+ case 'non-finite-covariates':
272
+ reason = error.code;
273
+ break;
274
+ }
275
+ }
276
+ metric.unavailable = reason;
261
277
  metric.perArm = [];
262
278
  metric.comparisons = [];
263
279
  metric.cupedApplied = false;
@@ -1,13 +1,14 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  import { buildRegressionAdjustedStatistic, buildStatistic, summarizeUnits, summarizeUnitsWithCovariate } from '@delmaredigital/experiments-stats';
2
3
  /** Build candidate CUPED statistics; the stats engine pools each pair, infers theta,
3
4
  * and falls back to the post statistic when pre-period variance/covariance is zero.
4
5
  * Missing units in a supplied pre-period map are zero-filled by the caller.
5
6
  */ export function outcomeStatistic(kind, values, covariates) {
6
7
  if (!values.every(Number.isFinite) || covariates && !covariates.every(Number.isFinite)) {
7
- throw new RangeError('Metric values and covariates must be finite');
8
+ throw new XpAnalysisError('cuped-non-finite');
8
9
  }
9
10
  if (kind === 'binomial' && (values.some((value)=>value !== 0 && value !== 1) || covariates?.some((value)=>value !== 0 && value !== 1))) {
10
- throw new RangeError('Binomial values and covariates must be 0 or 1');
11
+ throw new XpAnalysisError('cuped-binomial');
11
12
  }
12
13
  const moments = summarizeUnits(values);
13
14
  if (covariates === undefined) return {
@@ -1,16 +1,24 @@
1
1
  import type { ExposedUnit, MetricEvent, Timestamp } from './aggregate.js';
2
- /**
3
- * Spec §8.3: credit once to the earliest exposed linked visitor; conflicts are diagnostic only.
4
- * Callers must pass user ids through String() for both identities and user-keyed source events.
5
- */
6
- export declare function creditUserEvents(units: ReadonlyMap<string, ExposedUnit>, identities: readonly {
2
+ export interface IdentityLink {
7
3
  anonymousId: string;
8
4
  userId: string;
9
- }[], userEvents: readonly {
5
+ verifiedAt: Timestamp | null;
6
+ /** Earliest verification across all the user's browsers, exposed or not. */
7
+ ownedAt: Timestamp | null;
8
+ }
9
+ /** Unverified links credit only events before anyone proves account ownership.
10
+ * Never-verified accounts keep crediting without a time limit.
11
+ */
12
+ export declare function eligibleIdentityLinks<T extends IdentityLink>(links: readonly T[], ts: Timestamp): T[];
13
+ /** Credit each event to the earliest exposed eligible visitor, with a stable ID
14
+ * tie-break. A user counts once if eligible links span arms for a credited event.
15
+ * Callers must normalize user IDs with String().
16
+ */
17
+ export declare function creditUserEvents(units: ReadonlyMap<string, ExposedUnit>, identities: readonly IdentityLink[], userEvents: readonly {
10
18
  userId: string;
11
19
  value: number;
12
20
  ts: Timestamp;
13
- }[]): {
21
+ }[], onConflict?: (userId: string) => void): {
14
22
  events: MetricEvent[];
15
23
  conflicts: number;
16
24
  };
@@ -1,46 +1,45 @@
1
1
  import { timestampMs } from './aggregate.js';
2
- /**
3
- * Spec §8.3: credit once to the earliest exposed linked visitor; conflicts are diagnostic only.
4
- * Callers must pass user ids through String() for both identities and user-keyed source events.
5
- */ export function creditUserEvents(units, identities, userEvents) {
2
+ /** Unverified links credit only events before anyone proves account ownership.
3
+ * Never-verified accounts keep crediting without a time limit.
4
+ */ export function eligibleIdentityLinks(links, ts) {
5
+ const t = timestampMs(ts);
6
+ return links.filter((link)=>link.verifiedAt !== null || link.ownedAt === null || t < timestampMs(link.ownedAt));
7
+ }
8
+ /** Credit each event to the earliest exposed eligible visitor, with a stable ID
9
+ * tie-break. A user counts once if eligible links span arms for a credited event.
10
+ * Callers must normalize user IDs with String().
11
+ */ export function creditUserEvents(units, identities, userEvents, onConflict) {
12
+ const starts = new Map([
13
+ ...units
14
+ ].map(([id, unit])=>[
15
+ id,
16
+ timestampMs(unit.firstTs)
17
+ ]));
6
18
  const users = new Map();
7
- const starts = new Map();
8
- for (const [unitId, unit] of units)starts.set(unitId, timestampMs(unit.firstTs));
9
- for (const { anonymousId, userId } of identities){
10
- const unit = units.get(anonymousId);
11
- if (!unit) continue;
12
- const firstTs = starts.get(anonymousId);
13
- const user = users.get(userId);
14
- if (!user) users.set(userId, {
15
- unitId: anonymousId,
16
- firstTs,
17
- arms: new Set([
18
- unit.arm
19
- ])
20
- });
21
- else {
22
- user.arms.add(unit.arm);
23
- // Stable tie-break for simultaneous exposures, independent of identity row order.
24
- if (firstTs < user.firstTs || firstTs === user.firstTs && anonymousId < user.unitId) {
25
- user.unitId = anonymousId;
26
- user.firstTs = firstTs;
27
- }
28
- }
19
+ for (const link of identities){
20
+ if (!units.has(link.anonymousId)) continue;
21
+ const links = users.get(link.userId) ?? [];
22
+ links.push(link);
23
+ users.set(link.userId, links);
29
24
  }
30
- let conflicts = 0;
31
- for (const user of users.values())if (user.arms.size > 1) conflicts++;
25
+ for (const links of users.values())links.sort((a, b)=>starts.get(a.anonymousId) - starts.get(b.anonymousId) || (a.anonymousId < b.anonymousId ? -1 : a.anonymousId > b.anonymousId ? 1 : 0));
26
+ const conflicts = new Set();
32
27
  const events = [];
33
28
  for (const event of userEvents){
34
- timestampMs(event.ts);
35
- const user = users.get(event.userId);
36
- if (user) events.push({
37
- unitId: user.unitId,
29
+ const eligible = eligibleIdentityLinks(users.get(event.userId) ?? [], event.ts);
30
+ if (!eligible.length) continue;
31
+ events.push({
32
+ unitId: eligible[0].anonymousId,
38
33
  value: event.value,
39
34
  ts: event.ts
40
35
  });
36
+ if (new Set(eligible.map((link)=>units.get(link.anonymousId).arm)).size > 1) {
37
+ conflicts.add(event.userId);
38
+ onConflict?.(event.userId);
39
+ }
41
40
  }
42
41
  return {
43
42
  events,
44
- conflicts
43
+ conflicts: conflicts.size
45
44
  };
46
45
  }
@@ -1,7 +1,8 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  import { hash, layerSlice, rolloutBucketRanges } from '@delmaredigital/experiments-stats';
2
3
  import { timestampMs } from './aggregate.js';
3
4
  /** Spec §7.6/§8.1.5 (X1): first-ever rollout exposures inside assignment's exact ranges. */ export function rolloutAnalysisUnits({ exposuresAllPhases, rolloutPhase, seed, testWeights, coverage, layer }) {
4
- if (!seed.endsWith('|')) throw new RangeError('Assignment seed must end in |');
5
+ if (!seed.endsWith('|')) throw new XpAnalysisError('seed-format');
5
6
  const first = new Map();
6
7
  for (const row of exposuresAllPhases){
7
8
  const ts = timestampMs(row.ts);
@@ -1,3 +1,4 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  /** JSON cannot represent unbounded confidence limits. */ export function jsonInterval(ci) {
2
3
  return ci.map((bound)=>bound !== null && Number.isFinite(bound) ? bound : null);
3
4
  }
@@ -138,14 +139,19 @@ const shape = object({
138
139
  }),
139
140
  by: nullable(id)
140
141
  }))
142
+ }, {
143
+ health: list(object({
144
+ code: oneOf('version-page-unavailable', 'version-page-unchecked'),
145
+ path: string
146
+ }))
141
147
  });
142
148
  /** Validate the frozen JSON shape and headline/rollout invariants before storage.
143
149
  * This also accepts the shared CORE-13/admin fixtures without any I/O.
144
150
  */ export function validateSnapshot(value) {
145
- if (!shape(value)) throw new TypeError('Invalid Snapshot shape (all numbers must be finite)');
151
+ if (!shape(value)) throw new XpAnalysisError('snapshot-shape');
146
152
  const snapshot = value;
147
153
  if (snapshot.phaseKind === 'test' && snapshot.winnerArm !== null || snapshot.phaseKind === 'rollout' && (snapshot.winnerArm === null || !(snapshot.winnerArm in snapshot.exposures.perArm)) || snapshot.verdict.headline !== snapshot.verdict.finding + (snapshot.verdict.action ? ` ${snapshot.verdict.action}` : '') || new Date(snapshot.window.to).getTime() < new Date(snapshot.window.from).getTime()) {
148
- throw new TypeError('Invalid Snapshot invariants');
154
+ throw new XpAnalysisError('snapshot-invariants');
149
155
  }
150
156
  }
151
157
  /** Assemble the already-computed snapshot at the pure/I/O boundary. */ export function assembleSnapshot(snapshot) {
@@ -1,3 +1,4 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  import { checkSrm } from '@delmaredigital/experiments-stats';
2
3
  /** SRM uses exposed units and renormalised frozen weights, never allocation or
3
4
  * assignment counts. Rollout callers pass only shipped/holdout weights (100-h,h).
@@ -5,7 +6,7 @@ import { checkSrm } from '@delmaredigital/experiments-stats';
5
6
  */ export function exposureSrm(arms, perArm) {
6
7
  const active = arms.filter((arm)=>arm.weight > 0);
7
8
  const weight = active.reduce((sum, arm)=>sum + arm.weight, 0);
8
- if (weight <= 0) throw new RangeError('SRM requires positive weights');
9
+ if (weight <= 0) throw new XpAnalysisError('srm-weights');
9
10
  const expected = Object.fromEntries(arms.map((arm)=>[
10
11
  arm.key,
11
12
  arm.weight / weight
@@ -78,6 +78,8 @@ interface VerdictInputFields {
78
78
  originalLabel: string;
79
79
  controlArm: string;
80
80
  }
81
+ /** Resolve a stored group label without exposing an internal key in verdict copy. */
82
+ export declare function verdictGroupLabel(key: string, label?: string): string;
81
83
  /** Apply spec §8.5 in order to already-computed results; no inference is performed here. */
82
84
  export declare function computeVerdict(input: VerdictInput): Snapshot['verdict'];
83
85
  export {};
@@ -1,3 +1,4 @@
1
+ import { ARM_LABELS } from '../labels/arms.js';
1
2
  function percent(value) {
2
3
  if (value === null) return '…';
3
4
  const rounded = Math.round(value * 100);
@@ -19,6 +20,9 @@ function chance(value) {
19
20
  if (value === undefined) return '';
20
21
  return value >= 0.995 ? ' (more than 99%)' : ` (${Math.floor(value * 100)}%)`;
21
22
  }
23
+ /** Resolve a stored group label without exposing an internal key in verdict copy. */ export function verdictGroupLabel(key, label = '') {
24
+ return label.trim() || (Object.hasOwn(ARM_LABELS, key) ? ARM_LABELS[key] : 'This group');
25
+ }
22
26
  /** Apply spec §8.5 in order to already-computed results; no inference is performed here. */ export function computeVerdict(input) {
23
27
  const rollout = input.phaseKind === 'rollout';
24
28
  const make = (row, code, finding, action, detail, recommendedArm, line, worstArm)=>({
@@ -60,7 +64,20 @@ function chance(value) {
60
64
  }
61
65
  const failing = input.guardrails.find((item)=>item.status === 'failing');
62
66
  if (failing) return make(3, 'guardrail-failing', `Stop: ${failing.worstArmLabel} harms ${failing.name}.`, null, 'An outcome that must stay safe has become worse.', input.controlArm, undefined, failing.worstArm);
63
- if (collecting) return make(4, 'collecting', input.status === 'stopped' ? 'Stopped before enough data was collected.' : `Collecting data: ${minimum} of ${input.exposures.minExposuresPerArm} people, ${minimumConverters} of ${input.primary.minSampleSize} conversions.`, null, 'More people or conversions are needed before drawing a conclusion.', null);
67
+ if (collecting) {
68
+ const smallest = compared.find((item)=>item.n === minimum);
69
+ const fewest = converters.find((item)=>item.converters === minimumConverters);
70
+ const groupLabel = (group)=>{
71
+ if (!group || group.kind === 'control') return 'Original';
72
+ const label = input.exposures.perArm.find((item)=>item.arm === group.arm)?.label ?? input.primary.comparisons.find((item)=>item.arm === group.arm)?.label;
73
+ return verdictGroupLabel(group.arm, label);
74
+ };
75
+ const detail = [
76
+ minimum < input.exposures.minExposuresPerArm ? `Smallest group so far: ${groupLabel(smallest)}, ${minimum} of the ${input.exposures.minExposuresPerArm} people it needs.` : '',
77
+ minimumConverters < input.primary.minSampleSize ? `Fewest conversions so far: ${groupLabel(fewest)}, ${minimumConverters} of the ${input.primary.minSampleSize} needed.` : ''
78
+ ].filter(Boolean).join(' ') || 'More people or conversions are needed before drawing a conclusion.';
79
+ return make(4, 'collecting', input.status === 'stopped' ? 'Stopped before enough data was collected.' : 'Collecting data.', null, input.status === 'stopped' ? 'More people or conversions are needed before drawing a conclusion.' : detail, null);
80
+ }
64
81
  const variantKeys = new Set(compared.filter((item)=>item.kind === 'variant').map((item)=>item.arm));
65
82
  const variants = input.primary.comparisons.filter((item)=>variantKeys.has(item.arm));
66
83
  const better = variants.filter((item)=>item.significant === 'better');
@@ -0,0 +1,7 @@
1
+ import type { ReadinessCode } from './types/endpoints.js';
2
+ /** Summary details contain identifiers only. Project known shapes rather than
3
+ * recursively copying host objects: titles, messages and placement detail are text.
4
+ * Other checks use authored client fallbacks and need no detail (including slot
5
+ * counts and layer capacity, which are not identifiers).
6
+ */
7
+ export declare function sanitizeReadinessDetail(code: ReadinessCode, detail: unknown): unknown;
@@ -0,0 +1,54 @@
1
+ const object = (value)=>value !== null && typeof value === 'object' && !Array.isArray(value);
2
+ // The runtime's slug/key alphabet, bounded even for host-provided parameter keys.
3
+ const key = (value)=>typeof value === 'string' && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value);
4
+ const arm = (value)=>typeof value === 'string' && [
5
+ 'control',
6
+ 'variantA',
7
+ 'variantB',
8
+ 'variantC',
9
+ 'holdout'
10
+ ].includes(value);
11
+ const failure = (value)=>value === 'placements-failed' || value === 'forbidden' || value === 'not-found';
12
+ /** Summary details contain identifiers only. Project known shapes rather than
13
+ * recursively copying host objects: titles, messages and placement detail are text.
14
+ * Other checks use authored client fallbacks and need no detail (including slot
15
+ * counts and layer capacity, which are not identifiers).
16
+ */ export function sanitizeReadinessDetail(code, detail) {
17
+ switch(code){
18
+ case 'block-path-rule':
19
+ return object(detail) && Array.isArray(detail.rules) && detail.rules.length && detail.rules.every((index)=>Number.isSafeInteger(index) && index >= 0) ? {
20
+ rules: detail.rules
21
+ } : undefined;
22
+ case 'no-placements':
23
+ return failure(detail) ? detail : undefined;
24
+ case 'page-target-unpublished':
25
+ // Paths only: no query, fragment, whitespace, URL authority or encoded text.
26
+ return typeof detail === 'string' && detail.length <= 128 && detail.startsWith('/') && !detail.includes('//') && !/[^A-Za-z0-9_/-]/.test(detail) ? detail : undefined;
27
+ case 'flag-params-invalid':
28
+ {
29
+ if (!Array.isArray(detail) || !detail.length || !detail.every((item)=>object(item) && arm(item.arm) && key(item.field))) return undefined;
30
+ return detail.map((item)=>({
31
+ arm: item.arm,
32
+ field: item.field
33
+ }));
34
+ }
35
+ case 'flag-placement-missing':
36
+ {
37
+ if (failure(detail)) return detail;
38
+ if (!object(detail) || !Array.isArray(detail.missing) || !detail.missing.every(arm) || !Array.isArray(detail.placements) || !detail.placements.every((item)=>object(item) && arm(item.arm))) return undefined;
39
+ return {
40
+ missing: detail.missing,
41
+ placements: detail.placements.map((item)=>({
42
+ arm: item.arm
43
+ }))
44
+ };
45
+ }
46
+ case 'undeclared-targeting-key':
47
+ case 'invalid-targeting-rule':
48
+ return object(detail) && Array.isArray(detail.attributes) && detail.attributes.length && detail.attributes.every(key) ? {
49
+ attributes: detail.attributes
50
+ } : undefined;
51
+ default:
52
+ return undefined;
53
+ }
54
+ }
@@ -1,4 +1,12 @@
1
1
  import type { ExperimentsServerRuntime } from '../types/runtime.js';
2
+ import { type IdentityWriteOutcome } from '../db/identities.js';
3
+ export type LinkIdentityOutcome = IdentityWriteOutcome | 'failed';
4
+ /**
5
+ * verified: true means this request proves, in this browser, that the person owns
6
+ * the account: a magic-link or one-time-code callback, password sign-in, or OAuth
7
+ * callback. It does not mean "a session exists" or "the email is verified".
8
+ * Never throws into registration or sign-in.
9
+ */
2
10
  export declare function linkIdentity<TUser extends {
3
11
  id: string | number;
4
12
  } = {
@@ -6,4 +14,5 @@ export declare function linkIdentity<TUser extends {
6
14
  }>(serverRuntime: ExperimentsServerRuntime<TUser>, opts: {
7
15
  headers: Headers;
8
16
  userId: string;
9
- }): Promise<void>;
17
+ verified: boolean;
18
+ }): Promise<LinkIdentityOutcome>;
@@ -1,34 +1,36 @@
1
+ import { safeError } from '../log.js';
1
2
  import { NextRequest } from 'next/server.js';
2
3
  import { insertIdentityOnOwnConnection } from '../db/identities.js';
3
4
  import { readUnitCookie } from '../runtime/cookie.js';
4
- export async function linkIdentity(serverRuntime, opts) {
5
+ /**
6
+ * verified: true means this request proves, in this browser, that the person owns
7
+ * the account: a magic-link or one-time-code callback, password sign-in, or OAuth
8
+ * callback. It does not mean "a session exists" or "the email is verified".
9
+ * Never throws into registration or sign-in.
10
+ */ export async function linkIdentity(serverRuntime, opts) {
5
11
  let payload;
6
12
  try {
7
13
  const runtime = serverRuntime.proxyRuntime;
8
14
  // Consent sees cookies and headers only; the URL is a placeholder.
9
- let consent = true;
10
- try {
11
- consent = runtime.cookie.consent?.(new NextRequest('http://consent.invalid/', {
12
- headers: opts.headers
13
- })) !== false;
14
- } catch {
15
- console.error('[payload-experiments] consent failed; treating consent as false');
16
- consent = false;
17
- }
18
- if (!consent) return;
15
+ const consent = runtime.cookie.consent?.(new NextRequest('http://consent.invalid/', {
16
+ headers: opts.headers
17
+ })) !== false;
18
+ if (!consent) return 'skipped';
19
19
  const anonymousId = readUnitCookie(opts.headers, runtime);
20
- if (!anonymousId) return;
20
+ if (!anonymousId) return 'skipped';
21
21
  payload = await serverRuntime.getPayload();
22
- await insertIdentityOnOwnConnection(payload, {
22
+ return await insertIdentityOnOwnConnection(payload, {
23
23
  anonymousId,
24
- userId: opts.userId
24
+ userId: opts.userId,
25
+ verified: opts.verified
25
26
  });
26
27
  } catch (error) {
27
28
  try {
28
- payload?.logger.error({
29
- err: error,
30
- msg: 'Experiments: linkIdentity failed'
31
- });
29
+ if (payload) payload.logger.error({
30
+ err: safeError(error)
31
+ }, 'Experiments: linkIdentity failed');
32
+ else console.error('[payload-experiments] linkIdentity failed', safeError(error));
32
33
  } catch {}
34
+ return 'failed';
33
35
  }
34
36
  }
@@ -1,5 +1,10 @@
1
1
  import type { Metadata } from 'next';
2
2
  import type { ExperimentsServerRuntime } from '../types/runtime.js';
3
+ /**
4
+ * Reads the signed request context via `headers()`, so it decides from the URL the visitor requested, not the
5
+ * rewritten route. Call it only from a dynamic `generateMetadata`, never inside `'use cache'`. `path` is the
6
+ * fallback when the proxy did not run.
7
+ */
3
8
  export declare function experimentMetadata<TUser extends {
4
9
  id: string | number;
5
10
  } = {
@@ -1,6 +1,14 @@
1
1
  import { findNormalisedPathKey, normalisePath } from '../runtime/normalisePath.js';
2
+ import { headers } from 'next/headers.js';
3
+ import { serverRequest } from './request.js';
2
4
  import { serverManifest } from './context.js';
3
- export async function experimentMetadata(serverRuntime, path) {
5
+ /**
6
+ * Reads the signed request context via `headers()`, so it decides from the URL the visitor requested, not the
7
+ * rewritten route. Call it only from a dynamic `generateMetadata`, never inside `'use cache'`. `path` is the
8
+ * fallback when the proxy did not run.
9
+ */ export async function experimentMetadata(serverRuntime, path) {
10
+ const { request, hasContext } = serverRequest(new Headers(await headers()), serverRuntime.proxyRuntime.secrets);
11
+ if (hasContext) path = request.nextUrl.pathname;
4
12
  const manifest = await serverManifest(serverRuntime.proxyRuntime);
5
13
  const live = manifest?.experiments.find((e)=>e.kind === 'page' && e.arms.some((a)=>a.pageTarget !== undefined && normalisePath(a.pageTarget) === normalisePath(path)));
6
14
  if (live) {
@@ -1,3 +1,4 @@
1
+ import { safeError } from '../log.js';
1
2
  import { isDeepStrictEqual } from 'node:util';
2
3
  import { appendContentChange, contentChangeSavepoint, previousPublishedContent, runningContentExperiment } from '../db/appends.js';
3
4
  import { txExecutor } from '../db/executor.js';
@@ -75,7 +76,7 @@ const baselineKey = (collection, id)=>`${collection}:${id}`;
75
76
  try {
76
77
  await auditContentChange(args);
77
78
  } catch (error) {
78
- console.error('[payload-experiments] contentChanges failed; the document was saved without it', error);
79
+ console.error('[payload-experiments] contentChanges failed; the document was saved without it', safeError(error));
79
80
  }
80
81
  }
81
82
  async function auditContentChange({ doc, previousDoc, req, collection }) {
@@ -1,3 +1,4 @@
1
+ import { safeError } from '../log.js';
1
2
  import { pooledExecutor } from '../db/executor.js';
2
3
  import { insertEvents } from '../db/events.js';
3
4
  import { exposureDimensions } from '../events/dimensions.js';
@@ -48,9 +49,8 @@ export async function recordExposures(serverRuntime, rendered, ctx) {
48
49
  } catch (error) {
49
50
  try {
50
51
  payload?.logger.error({
51
- err: error,
52
- msg: 'Experiments: recordExposures failed'
53
- });
52
+ err: safeError(error)
53
+ }, 'Experiments: recordExposures failed');
54
54
  } catch {}
55
55
  }
56
56
  }
@@ -1,10 +1,11 @@
1
- import type { Payload } from 'payload';
1
+ import { type Payload } from 'payload';
2
+ import type { MetricUnavailableReason } from '../types/reasons.js';
2
3
  import type { DocId } from '../types/contracts.js';
3
4
  type UnavailableMetric = {
4
5
  experimentId: DocId;
5
6
  phase: number;
6
7
  metricId: DocId;
7
- reason: string;
8
+ reason: MetricUnavailableReason;
8
9
  };
9
10
  export declare function runResults(payload: Payload, { budgetMs, experimentId, sweep }?: {
10
11
  budgetMs?: number;