@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/.agents/skills/onboarding-journeys/SKILL.md +23 -3
- package/corpus/templates/analytics/CHANGELOG.md +2 -0
- package/corpus/templates/analytics/actions/get-onboarding-journey.ts +1 -1
- package/corpus/templates/analytics/agent-native.json +8 -1
- package/corpus/templates/analytics/changelog/2026-10-09-journey-screenshots-account-for-legacy-clipping-when-checkin.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-09-onboarding-journeys-count-saved-clips-and-completed-deck-gen.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-09-session-recordings-keep-their-full-history-and-performance-i.md +6 -0
- package/corpus/templates/analytics/scripts/journey-capture-iframe-audit.ts +110 -14
- package/corpus/templates/analytics/server/db/index.ts +19 -0
- package/corpus/templates/analytics/server/db/schema.ts +19 -0
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +123 -9
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +383 -2
- package/corpus/templates/analytics/server/lib/error-capture.ts +111 -5
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +70 -2
- package/corpus/templates/analytics/server/lib/journey-steps.ts +93 -8
- package/corpus/templates/analytics/server/lib/onboarding-journey.ts +2 -0
- package/corpus/templates/analytics/server/lib/session-event-index.ts +137 -16
- package/corpus/templates/analytics/server/lib/session-friction.ts +396 -96
- package/corpus/templates/analytics/server/lib/session-performance.ts +273 -51
- package/corpus/templates/analytics/server/lib/session-recording-associations.ts +28 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +262 -97
- package/corpus/templates/analytics/server/plugins/db.ts +13 -0
- package/corpus/templates/brain/.agents/skills/ingestion-and-connectors/SKILL.md +20 -0
- package/corpus/templates/brain/actions/_source-config.ts +2 -1
- package/corpus/templates/brain/actions/list-connection-providers.ts +1 -1
- package/corpus/templates/brain/app/i18n-data.ts +10 -0
- package/corpus/templates/brain/app/routes/sources.tsx +22 -1
- package/corpus/templates/brain/changelog/2026-10-07-zoom-sources-can-import-ai-companion-summaries.md +6 -0
- package/corpus/templates/brain/server/lib/connectors.ts +142 -14
- package/corpus/templates/brain/server/lib/zoom.ts +126 -0
- package/corpus/templates/content/actions/_content-save-outcomes.ts +280 -0
- package/corpus/templates/content/actions/_document-edit-mutation.ts +91 -24
- package/corpus/templates/content/actions/create-document.ts +9 -3
- package/corpus/templates/content/actions/edit-document.ts +43 -2
- package/corpus/templates/content/actions/record-editor-mount.ts +55 -0
- package/corpus/templates/content/actions/resolve-preview-document-draft.ts +6 -2
- package/corpus/templates/content/actions/update-document.ts +110 -14
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +64 -43
- package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +1 -1
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +29 -0
- package/corpus/templates/content/app/components/editor/editor-mount-telemetry.ts +76 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +5 -1
- package/corpus/templates/content/app/lib/content-save-telemetry.ts +17 -0
- package/corpus/templates/content/e2e/helpers.ts +104 -14
- package/corpus/templates/content/e2e/save-lineage.ts +519 -0
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +5 -1
- package/corpus/templates/content/shared/editor-mount-outcomes.ts +13 -0
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +5 -6
- package/corpus/templates/design/actions/generate-design.ts +43 -23
- package/corpus/templates/design/actions/present-design-variants.ts +5 -16
- package/corpus/templates/design/app/hooks/use-question-flow.ts +23 -7
- package/corpus/templates/design/app/pages/design-editor/generation-prompt-directives.ts +18 -4
- package/corpus/templates/design/changelog/2026-10-09-ads-and-social-posts-keep-their-exact-size-with-no-extra-mob.md +6 -0
- package/corpus/templates/design/e2e/loopback-design-provider.ts +44 -0
- package/corpus/templates/design/e2e/sql-inline-bytes-scan.ts +328 -0
- package/corpus/templates/design/playwright.config.ts +1 -0
- package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/design/shared/canvas-dimensions.ts +24 -13
- package/corpus/templates/design/shared/specified-design-prompt.ts +15 -0
- package/corpus/templates/slides/.agents/skills/slide-images/SKILL.md +6 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +410 -228
- package/corpus/templates/slides/app/pages/Index.tsx +1 -0
- package/corpus/templates/slides/changelog/2026-10-09-slides-keep-generating-when-image-search-is-unavailable.md +6 -0
- package/corpus/templates/slides/server/lib/generation-completion.ts +4 -5
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -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,
|
|
149
|
-
clip
|
|
150
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
|
795
|
-
"
|
|
796
|
-
"
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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 (
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
}
|