@agent-native/core-corpus 0.206.0 → 0.206.1-nightly-20261009183700

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 (67) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/onboarding-journeys/SKILL.md +23 -3
  3. package/corpus/templates/analytics/CHANGELOG.md +2 -0
  4. package/corpus/templates/analytics/actions/get-onboarding-journey.ts +1 -1
  5. package/corpus/templates/analytics/agent-native.json +8 -1
  6. package/corpus/templates/analytics/changelog/2026-10-09-journey-screenshots-account-for-legacy-clipping-when-checkin.md +6 -0
  7. package/corpus/templates/analytics/changelog/2026-10-09-onboarding-journeys-count-saved-clips-and-completed-deck-gen.md +6 -0
  8. package/corpus/templates/analytics/changelog/2026-10-09-session-recordings-keep-their-full-history-and-performance-i.md +6 -0
  9. package/corpus/templates/analytics/scripts/journey-capture-iframe-audit.ts +110 -14
  10. package/corpus/templates/analytics/server/db/index.ts +19 -0
  11. package/corpus/templates/analytics/server/db/schema.ts +19 -0
  12. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +123 -9
  13. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +383 -2
  14. package/corpus/templates/analytics/server/lib/error-capture.ts +111 -5
  15. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +70 -2
  16. package/corpus/templates/analytics/server/lib/journey-steps.ts +93 -8
  17. package/corpus/templates/analytics/server/lib/onboarding-journey.ts +2 -0
  18. package/corpus/templates/analytics/server/lib/session-event-index.ts +137 -16
  19. package/corpus/templates/analytics/server/lib/session-friction.ts +396 -96
  20. package/corpus/templates/analytics/server/lib/session-performance.ts +273 -51
  21. package/corpus/templates/analytics/server/lib/session-recording-associations.ts +28 -0
  22. package/corpus/templates/analytics/server/lib/session-replay.ts +262 -97
  23. package/corpus/templates/analytics/server/plugins/db.ts +13 -0
  24. package/corpus/templates/brain/.agents/skills/ingestion-and-connectors/SKILL.md +20 -0
  25. package/corpus/templates/brain/actions/_source-config.ts +2 -1
  26. package/corpus/templates/brain/actions/list-connection-providers.ts +1 -1
  27. package/corpus/templates/brain/app/i18n-data.ts +10 -0
  28. package/corpus/templates/brain/app/routes/sources.tsx +22 -1
  29. package/corpus/templates/brain/changelog/2026-10-07-zoom-sources-can-import-ai-companion-summaries.md +6 -0
  30. package/corpus/templates/brain/server/lib/connectors.ts +142 -14
  31. package/corpus/templates/brain/server/lib/zoom.ts +126 -0
  32. package/corpus/templates/content/actions/_content-save-outcomes.ts +280 -0
  33. package/corpus/templates/content/actions/_document-edit-mutation.ts +91 -24
  34. package/corpus/templates/content/actions/create-document.ts +9 -3
  35. package/corpus/templates/content/actions/edit-document.ts +43 -2
  36. package/corpus/templates/content/actions/record-editor-mount.ts +55 -0
  37. package/corpus/templates/content/actions/resolve-preview-document-draft.ts +6 -2
  38. package/corpus/templates/content/actions/update-document.ts +110 -14
  39. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +64 -43
  40. package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +1 -1
  41. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +29 -0
  42. package/corpus/templates/content/app/components/editor/editor-mount-telemetry.ts +76 -0
  43. package/corpus/templates/content/app/hooks/use-documents.ts +5 -1
  44. package/corpus/templates/content/app/lib/content-save-telemetry.ts +17 -0
  45. package/corpus/templates/content/e2e/helpers.ts +104 -14
  46. package/corpus/templates/content/e2e/save-lineage.ts +519 -0
  47. package/corpus/templates/content/parity/matrix.md +1 -1
  48. package/corpus/templates/content/parity/matrix.ts +5 -1
  49. package/corpus/templates/content/shared/editor-mount-outcomes.ts +13 -0
  50. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +5 -6
  51. package/corpus/templates/design/actions/generate-design.ts +43 -23
  52. package/corpus/templates/design/actions/present-design-variants.ts +5 -16
  53. package/corpus/templates/design/app/hooks/use-question-flow.ts +23 -7
  54. package/corpus/templates/design/app/pages/design-editor/generation-prompt-directives.ts +18 -4
  55. package/corpus/templates/design/changelog/2026-10-09-ads-and-social-posts-keep-their-exact-size-with-no-extra-mob.md +6 -0
  56. package/corpus/templates/design/e2e/loopback-design-provider.ts +44 -0
  57. package/corpus/templates/design/e2e/sql-inline-bytes-scan.ts +328 -0
  58. package/corpus/templates/design/playwright.config.ts +1 -0
  59. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
  60. package/corpus/templates/design/shared/canvas-dimensions.ts +24 -13
  61. package/corpus/templates/design/shared/specified-design-prompt.ts +15 -0
  62. package/corpus/templates/slides/.agents/skills/slide-images/SKILL.md +6 -0
  63. package/corpus/templates/slides/app/pages/DeckEditor.tsx +410 -228
  64. package/corpus/templates/slides/app/pages/Index.tsx +1 -0
  65. package/corpus/templates/slides/changelog/2026-10-09-slides-keep-generating-when-image-search-is-unavailable.md +6 -0
  66. package/corpus/templates/slides/server/lib/generation-completion.ts +4 -5
  67. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -32,4 +32,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core-corpus/corpu
32
32
 
33
33
  ## Generated Counts
34
34
 
35
- - template files: 11704
35
+ - template files: 11719
@@ -47,6 +47,25 @@ only to the Design input, set `referenceOnly: true`, and omit the cohort metric
47
47
  fields. This annotation is for visual references and is not emitted by
48
48
  `get-onboarding-journey`.
49
49
 
50
+ Saved outputs require source-confirmed completion events. Clips uses
51
+ `recording_ready`; `recording_started`, transcript-only `recording_completed`,
52
+ and `clip_viewed` are not saved outputs. Slides uses explicit
53
+ `generation_completed`; `generation_request_accepted`,
54
+ `generation_outcome_unresolved`, `generation_failed`, `generation_stuck`,
55
+ `generation_cancelled`, and `generation_abandoned` remain distinct attempt
56
+ steps. An unresolved event can carry `persisted_output: true`, but it still does
57
+ not substitute for `generation_completed`. `deck_edited`, `output_viewed`, and
58
+ current deck state also cannot substitute for that event. A missing terminal
59
+ event stays absent. Generation and recording starts remain attempt steps, and
60
+ retry attempts that reuse a deck's output ID remain separate by their exact
61
+ attempt ID. A sessionless Clips completion or Slides lifecycle event is
62
+ attached only when its exact output and attempt pair maps to one distinct
63
+ eligible onboarding session (`recording_started` for Clips;
64
+ `generation_started`, `generation_request_accepted`, or `output_viewed` for
65
+ Slides); missing or ambiguous
66
+ matches stay unattributed. Output and attempt IDs are never included in the
67
+ returned tree.
68
+
50
69
  The journey projection also retains `integration_setup_exposed`,
51
70
  `integration_method_clicked`, and `integration_method_outcome` as separate
52
71
  `integration:<flow>:...` steps. These record setup exposure, the selected
@@ -145,9 +164,10 @@ chunk. Fix failures or report them; do not paint over a missing frame.
145
164
  the PNG. Every visible iframe must have a corresponding recorded child document
146
165
  by the requested replay time. If the child document is missing, the frame is
147
166
  listed as a failure with code `replay_iframe_content_unavailable` and counts in
148
- `diagnostics`. If unsupported 3D projection, rounded ancestor clipping, CSS
149
- clip paths, masks, or visibility-altering filters prevent the audit from
150
- verifying visibility, the frame is listed with code
167
+ `diagnostics`. If unsupported 3D projection, rounded ancestor clipping,
168
+ unsupported CSS `clip-path` shapes, unrecognized legacy CSS `clip` values,
169
+ masks, or visibility-altering filters prevent the audit from verifying
170
+ visibility, the frame is listed with code
151
171
  `replay_iframe_visibility_unverifiable`; do not treat uncertainty as either
152
172
  missing content or a successful audit. The check covers every visible iframe
153
173
  because replay can omit its original source attribute while rebuilding an
@@ -15,6 +15,8 @@ time from the command menu (Cmd+K → "What's new") or from Settings.
15
15
 
16
16
  - Journey captures now flag visible previews missing from the recording instead of saving blank screenshots
17
17
  - Journey recordings flag iframe content when clipping, masks, or filters make its visibility uncertain.
18
+ - Onboarding journeys count saved clips and completed deck generations as outputs while showing Slides attempt outcomes separately
19
+ - Session recordings keep their full history and performance insights across browser session changes and large replays.
18
20
  - Large onboarding journeys keep their step counts when follow-up coverage exceeds query limits
19
21
 
20
22
  ## 2026-10-08
@@ -34,7 +34,7 @@ const isoDate = z
34
34
 
35
35
  export default defineAction({
36
36
  description:
37
- 'Build the onboarding journey tree for a date window: per-session step sequences (signup, onboarding questions, setup method, app entry, first action, first output) folded into a prefix tree. format "tree" returns { window, app, rootN, coverage, nodes } for sessions that entered onboarding, plus standaloneSetup for Home chat integration events in sessions without onboarding; its root and percentages have a separate denominator. Each node has n, pctOfRoot, pctOfParent, dropoffN/dropoffPct (last observed step, not a confirmed exit), deeperN (sessions with a later observed step omitted below this node), and example sessions with recordingId, a recording-start-relative offsetMs, and viewport. Both formats include followUp counts of sessions with and without any later native Analytics event in the same session after each terminal selected step, observed before one frozen observationCutoff. followUp.rightCensoredAtWindowEnd is true: no later event means none was recorded within that bounded window, not abandonment or churn. If the event read may be incomplete, the aggregate query truncates or exceeds the SQL size or parser-token limit, or cohort coverage mismatches, followUp.status is incomplete, incompleteReason names the cause, and its cohort counts and duration are null. format "summary" returns the same tree counts as indented outlines with no examples. Check each tree\'s coverage.truncated first: true means its event sample, requested depth, or node list cut that tree; notes explain why. Use it to plan onboarding storyboards or see each cohort\'s last recorded selected step. For a standalone storyboard, pass standaloneSetup as the top-level tree to journey:capture.',
37
+ 'Build a per-session onboarding journey tree from signup through the first saved output. Clips uses recording_ready; Slides uses explicit generation_completed; Design uses generation_completed or design_output_created. Generation and recording starts are attempt steps, not outputs. Slides request acceptance and failed, stuck, cancelled, abandoned, or unresolved outcomes are separate attempt steps; unresolved remains unresolved even when persisted_output is true, and a missing terminal event stays absent. Edits and views do not establish a saved output. A saved output or Slides attempt event without a session is attached only when its exact output/attempt pair matches exactly one onboarding cohort session; otherwise it stays unattributed. Output and attempt IDs are never returned. format "tree" returns { window, app, rootN, coverage, nodes } for sessions that entered onboarding, plus standaloneSetup for Home chat integration events in sessions without onboarding; its root and percentages have a separate denominator. Each node has n, pctOfRoot, pctOfParent, dropoffN/dropoffPct (last observed step, not a confirmed exit), deeperN (sessions with a later observed step omitted below this node), and example sessions with recordingId, a recording-start-relative offsetMs, and viewport. Both formats include followUp counts of sessions with and without any later native Analytics event in the same session after each terminal selected step, observed before one frozen observationCutoff. followUp.rightCensoredAtWindowEnd is true: no later event means none was recorded within that bounded window, not abandonment or churn. If the event read may be incomplete, the aggregate query truncates or exceeds the SQL size or parser-token limit, or cohort coverage mismatches, followUp.status is incomplete, incompleteReason names the cause, and its cohort counts and duration are null. format "summary" returns the same counts as indented outlines with no examples. Check each tree\'s coverage.truncated first: true means its event sample, requested depth, or node list cut that tree; notes explain why. Use it to plan onboarding storyboards or see each cohort\'s last recorded selected step. For a standalone storyboard, pass standaloneSetup as the top-level tree to journey:capture.',
38
38
  schema: z.object({
39
39
  dateFrom: isoDate.describe(
40
40
  "Inclusive UTC start date, YYYY-MM-DD. Sessions that began earlier appear mid-journey, so start a day before the period you care about.",
@@ -7,6 +7,13 @@
7
7
  }
8
8
  },
9
9
  "doctor": {
10
- "failOnBuild": false
10
+ "failOnBuild": false,
11
+ "dbToolScopingDenylist": {
12
+ "bigquery_cache": "provider cache, not a user-facing resource",
13
+ "dashboard_name_locks": "internal dashboard-name serialization rows, never exposed to raw DB tools",
14
+ "dashboard_views": "view telemetry, scoped by dashboard or action",
15
+ "first_party_analytics_cache": "internal query cache, accessed through scoped analytics queries",
16
+ "session_recording_session_associations": "child rows are scoped through authorized session recordings"
17
+ }
11
18
  }
12
19
  }
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-10-09
4
+ ---
5
+
6
+ Journey screenshots account for legacy clipping when checking embedded replay content.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-10-09
4
+ ---
5
+
6
+ Onboarding journeys count saved clips and completed deck generations as outputs while showing Slides attempt outcomes separately
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-10-09
4
+ ---
5
+
6
+ Session recordings keep their full history and performance insights across browser session changes and large replays.
@@ -413,6 +413,79 @@ export function auditReplayIframeContent({
413
413
  },
414
414
  ];
415
415
 
416
+ const [legacyClipPolygon]: [
417
+ (value: string, geometry: BoxGeometry) => Point[] | null,
418
+ ] = [
419
+ (value, geometry) => {
420
+ const match = value.trim().match(/^rect\((.*)\)$/i);
421
+ if (!match) return null;
422
+
423
+ const values = match[1]!
424
+ .trim()
425
+ .split(/\s*,\s*|\s+/)
426
+ .filter(Boolean);
427
+ if (values.length !== 4) return null;
428
+ type Offset = { kind: "auto" } | { kind: "value"; value: number };
429
+ const [parseOffset]: [(token: string) => Offset | null] = [
430
+ (token) => {
431
+ if (token.toLowerCase() === "auto") return { kind: "auto" };
432
+ const number = token.match(/^(-?(?:\d+(?:\.\d*)?|\.\d+))(px)?$/i);
433
+ if (!number) return null;
434
+ const amount = Number(number[1]);
435
+ if (!Number.isFinite(amount) || (!number[2] && amount !== 0)) {
436
+ return null;
437
+ }
438
+ return { kind: "value", value: amount };
439
+ },
440
+ ];
441
+ const offsets = values.map(parseOffset);
442
+ if (offsets.some((offset) => offset === null)) return null;
443
+ const [topOffset, rightOffset, bottomOffset, leftOffset] = offsets as [
444
+ Offset,
445
+ Offset,
446
+ Offset,
447
+ Offset,
448
+ ];
449
+ const [resolveOffset]: [
450
+ (offset: Offset, automatic: number | null) => number | null,
451
+ ] = [
452
+ (offset, automatic) =>
453
+ offset.kind === "auto" ? automatic : offset.value,
454
+ ];
455
+ const top = topOffset.kind === "auto" ? 0 : topOffset.value;
456
+ const left = leftOffset.kind === "auto" ? 0 : leftOffset.value;
457
+ const right = resolveOffset(
458
+ rightOffset,
459
+ geometry.uncertain ? null : geometry.width,
460
+ );
461
+ const bottom = resolveOffset(
462
+ bottomOffset,
463
+ geometry.uncertain ? null : geometry.height,
464
+ );
465
+ if (
466
+ (right !== null && right <= left) ||
467
+ (bottom !== null && bottom <= top)
468
+ ) {
469
+ return [];
470
+ }
471
+ if (
472
+ geometry.uncertain ||
473
+ right === null ||
474
+ bottom === null ||
475
+ geometry.width <= 0 ||
476
+ geometry.height <= 0
477
+ ) {
478
+ return null;
479
+ }
480
+ return [
481
+ { x: left, y: top },
482
+ { x: right, y: top },
483
+ { x: right, y: bottom },
484
+ { x: left, y: bottom },
485
+ ];
486
+ },
487
+ ];
488
+
416
489
  const [polygonMayOverlapLocalRects]: [
417
490
  (
418
491
  polygon: Point[],
@@ -771,16 +844,17 @@ export function auditReplayIframeContent({
771
844
 
772
845
  for (let current: Element | null = frame; current; ) {
773
846
  const styles = view.getComputedStyle(current);
847
+ const boxless = styles.display === "contents";
774
848
  if (
775
849
  styles.display === "none" ||
776
850
  styles.contentVisibility === "hidden" ||
777
- (styles.opacity !== "" && Number(styles.opacity) === 0)
851
+ (!boxless && styles.opacity !== "" && Number(styles.opacity) === 0)
778
852
  ) {
779
853
  rendered = false;
780
854
  break;
781
855
  }
782
856
  const filter = styles.getPropertyValue("filter") || styles.filter;
783
- if (filter && filter !== "none") {
857
+ if (!boxless && filter && filter !== "none") {
784
858
  if (
785
859
  /(?:^|\s)opacity\(\s*(?:0+(?:\.0*)?|\.0+)%?\s*\)(?:\s|$)/i.test(
786
860
  filter,
@@ -791,24 +865,46 @@ export function auditReplayIframeContent({
791
865
  }
792
866
  visibilityUncertain = true;
793
867
  }
794
- const hasUnsupportedMask = [
795
- "mask-image",
796
- "-webkit-mask-image",
797
- "mask-border-source",
798
- "-webkit-mask-box-image-source",
799
- ].some((property) => {
800
- const value =
801
- styles.getPropertyValue(property) ||
802
- (current as HTMLElement).style?.getPropertyValue(property);
803
- return value !== undefined && value !== "" && value !== "none";
804
- });
868
+ const legacyClip =
869
+ styles.getPropertyValue("clip") ||
870
+ (current as HTMLElement).style?.getPropertyValue("clip");
871
+ if (
872
+ legacyClip &&
873
+ legacyClip !== "auto" &&
874
+ !boxless &&
875
+ (styles.position === "absolute" || styles.position === "fixed")
876
+ ) {
877
+ const geometry = geometryFor(current, view);
878
+ const clipPolygon = legacyClipPolygon(legacyClip, geometry);
879
+ if (clipPolygon) {
880
+ visiblePolygon = intersectPolygons(
881
+ visiblePolygon,
882
+ clipPolygon.map((point) => pointToScreen(geometry, point)),
883
+ );
884
+ } else {
885
+ visibilityUncertain = true;
886
+ }
887
+ }
888
+ const hasUnsupportedMask =
889
+ !boxless &&
890
+ [
891
+ "mask-image",
892
+ "-webkit-mask-image",
893
+ "mask-border-source",
894
+ "-webkit-mask-box-image-source",
895
+ ].some((property) => {
896
+ const value =
897
+ styles.getPropertyValue(property) ||
898
+ (current as HTMLElement).style?.getPropertyValue(property);
899
+ return value !== undefined && value !== "" && value !== "none";
900
+ });
805
901
  if (hasUnsupportedMask) visibilityUncertain = true;
806
902
  const clipPath =
807
903
  styles.getPropertyValue("clip-path") ||
808
904
  styles.getPropertyValue("-webkit-clip-path") ||
809
905
  (current as HTMLElement).style?.getPropertyValue("clip-path") ||
810
906
  (current as HTMLElement).style?.getPropertyValue("-webkit-clip-path");
811
- if (clipPath && clipPath !== "none") {
907
+ if (!boxless && clipPath && clipPath !== "none") {
812
908
  const geometry = geometryFor(current, view);
813
909
  const clippedByShape = insetClipPolygon(clipPath, geometry);
814
910
  if (clippedByShape) {
@@ -1,5 +1,6 @@
1
1
  import { registerDataProgramsShareable } from "@agent-native/core/data-programs";
2
2
  import { createGetDb } from "@agent-native/core/db";
3
+ import { registerIdentityColumns } from "@agent-native/core/org";
3
4
  import { registerShareableResource } from "@agent-native/core/sharing";
4
5
 
5
6
  import {
@@ -15,6 +16,24 @@ export { schema };
15
16
 
16
17
  registerDataProgramsShareable();
17
18
 
19
+ registerIdentityColumns([
20
+ {
21
+ table: "session_recordings",
22
+ column: "session_id",
23
+ emailChange: "retain",
24
+ offboard: "retain",
25
+ reason:
26
+ "Legacy last-known session pointer for recordings predating association history.",
27
+ },
28
+ {
29
+ table: "session_recording_session_associations",
30
+ column: "session_id",
31
+ emailChange: "retain",
32
+ offboard: "retain",
33
+ reason: "Observed session ids preserve replay association history.",
34
+ },
35
+ ]);
36
+
18
37
  registerShareableResource({
19
38
  type: "dashboard",
20
39
  resourceTable: schema.dashboards,
@@ -786,6 +786,25 @@ export const sessionRecordingShares = createSharesTable(
786
786
  "session_recording_shares",
787
787
  );
788
788
 
789
+ export const sessionRecordingSessionAssociations = table(
790
+ "session_recording_session_associations",
791
+ {
792
+ id: text("id").primaryKey(),
793
+ recordingId: text("recording_id")
794
+ .notNull()
795
+ .references(() => sessionRecordings.id, { onDelete: "cascade" }),
796
+ sessionId: text("session_id").notNull(),
797
+ },
798
+ (association) => ({
799
+ recordingSessionUnique: uniqueIndex(
800
+ "session_recording_session_associations_recording_session_idx",
801
+ ).on(association.recordingId, association.sessionId),
802
+ sessionRecordingIdx: index(
803
+ "session_recording_session_associations_session_recording_idx",
804
+ ).on(association.sessionId, association.recordingId),
805
+ }),
806
+ );
807
+
789
808
  export const sessionReplayChunks = table(
790
809
  "session_replay_chunks",
791
810
  {
@@ -1,14 +1,23 @@
1
+ import { runWithRequestContext } from "@agent-native/core/server";
2
+
1
3
  import {
2
4
  claimAnalyticsAlertRuleEvaluation,
3
5
  ensureDefaultAnalyticsAlertRules,
4
6
  evaluateAndNotifyAnalyticsAlertRule,
7
+ evaluateBigQueryAnalyticsAlertBatch,
8
+ isBigQueryAnalyticsAlertBatchEligible,
5
9
  listEnabledAnalyticsAlertRules,
6
10
  markAnalyticsAlertRuleError,
11
+ prioritizeBigQueryAnalyticsAlertRules,
12
+ type AnalyticsAlertRule,
13
+ type AnalyticsAlertEvaluation,
7
14
  } from "../lib/analytics-alerts";
15
+ import { getFirstPartyAnalyticsBackend } from "../lib/first-party-analytics-backend.js";
8
16
 
9
17
  let running = false;
10
18
  let listRulesFailureLogged = false;
11
19
  const DEFAULT_MAX_RULES_PER_SWEEP = 100;
20
+ const MAX_BIGQUERY_RULES_PER_BATCH = 3;
12
21
 
13
22
  function maxRulesPerSweep(input?: number): number {
14
23
  if (input) return Math.max(1, Math.min(500, Math.floor(input)));
@@ -64,22 +73,127 @@ export async function runAnalyticsAlertsOnce(
64
73
  listRulesFailureLogged = false;
65
74
  remaining = rules.length >= sweepLimit ? 1 : 0;
66
75
 
67
- for (const rule of rules) {
76
+ async function failRule(rule: AnalyticsAlertRule, err: unknown) {
77
+ failed++;
78
+ try {
79
+ await markAnalyticsAlertRuleError(rule.id, err);
80
+ } catch (statusError) {
81
+ console.error(
82
+ `[analytics-alerts] Failed to mark rule ${rule.id} error:`,
83
+ statusError,
84
+ );
85
+ }
86
+ console.error(
87
+ `[analytics-alerts] Rule ${rule.id} (${rule.name}) failed:`,
88
+ err,
89
+ );
90
+ }
91
+
92
+ async function claimRule(rule: AnalyticsAlertRule): Promise<boolean> {
68
93
  try {
69
94
  const claimed = await claimAnalyticsAlertRuleEvaluation(rule);
70
- if (!claimed) continue;
71
- processed++;
72
- const result = await evaluateAndNotifyAnalyticsAlertRule(rule);
95
+ if (claimed) processed++;
96
+ return claimed;
97
+ } catch (err) {
98
+ await failRule(rule, err);
99
+ return false;
100
+ }
101
+ }
102
+
103
+ const batches = new Map<string, AnalyticsAlertRule[]>();
104
+ const individualRules: AnalyticsAlertRule[] = [];
105
+ for (const rule of rules) {
106
+ try {
107
+ if (!isBigQueryAnalyticsAlertBatchEligible(rule)) {
108
+ individualRules.push(rule);
109
+ continue;
110
+ }
111
+ const backend = await runWithRequestContext(
112
+ {
113
+ userEmail: rule.ownerEmail,
114
+ ...(rule.orgId !== null ? { orgId: rule.orgId } : {}),
115
+ },
116
+ () =>
117
+ getFirstPartyAnalyticsBackend({
118
+ userEmail: rule.ownerEmail,
119
+ orgId: rule.orgId,
120
+ }),
121
+ );
122
+ if (backend.sink !== "bigquery") {
123
+ individualRules.push(rule);
124
+ continue;
125
+ }
126
+ const key = JSON.stringify([rule.ownerEmail, rule.orgId]);
127
+ const batch = batches.get(key);
128
+ if (batch) batch.push(rule);
129
+ else batches.set(key, [rule]);
130
+ } catch (err) {
131
+ if (await claimRule(rule)) await failRule(rule, err);
132
+ }
133
+ }
134
+
135
+ async function notifyRule(
136
+ rule: AnalyticsAlertRule,
137
+ now: Date,
138
+ evaluation?: AnalyticsAlertEvaluation,
139
+ ) {
140
+ try {
141
+ const result = await evaluateAndNotifyAnalyticsAlertRule(
142
+ rule,
143
+ now,
144
+ evaluation,
145
+ );
73
146
  if (result.status === "triggered") triggered++;
147
+ if (result.status === "error") failed++;
74
148
  } catch (err) {
75
- failed++;
76
- await markAnalyticsAlertRuleError(rule.id, err).catch(() => {});
77
- console.error(
78
- `[analytics-alerts] Rule ${rule.id} (${rule.name}) failed:`,
79
- err,
149
+ await failRule(rule, err);
150
+ }
151
+ }
152
+
153
+ for (const rules of batches.values()) {
154
+ const scopedRules = prioritizeBigQueryAnalyticsAlertRules(rules);
155
+ for (
156
+ let offset = 0;
157
+ offset < scopedRules.length;
158
+ offset += MAX_BIGQUERY_RULES_PER_BATCH
159
+ ) {
160
+ const plannedBatch = scopedRules.slice(
161
+ offset,
162
+ offset + MAX_BIGQUERY_RULES_PER_BATCH,
80
163
  );
164
+ const batch: AnalyticsAlertRule[] = [];
165
+ for (const rule of plannedBatch) {
166
+ if (await claimRule(rule)) batch.push(rule);
167
+ }
168
+ if (!batch.length) continue;
169
+ const now = new Date();
170
+ let evaluations: Awaited<
171
+ ReturnType<typeof evaluateBigQueryAnalyticsAlertBatch>
172
+ >;
173
+ try {
174
+ evaluations = await evaluateBigQueryAnalyticsAlertBatch(batch, now);
175
+ } catch (err) {
176
+ for (const rule of batch) await failRule(rule, err);
177
+ continue;
178
+ }
179
+ for (const rule of batch) {
180
+ const result = evaluations.get(rule.id);
181
+ if (!result) {
182
+ await failRule(
183
+ rule,
184
+ new Error(`Missing analytics alert batch result: ${rule.id}`),
185
+ );
186
+ } else if ("error" in result) {
187
+ await failRule(rule, result.error);
188
+ } else {
189
+ await notifyRule(rule, now, result.evaluation);
190
+ }
191
+ }
81
192
  }
82
193
  }
194
+ for (const rule of individualRules) {
195
+ if (await claimRule(rule)) await notifyRule(rule, new Date());
196
+ }
83
197
  } finally {
84
198
  running = false;
85
199
  }