@agent-native/core 0.84.20 → 0.84.22
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/core/CHANGELOG.md +16 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/caller-auth.ts +37 -8
- package/corpus/core/src/a2a/client.ts +178 -67
- package/corpus/core/src/agent/production-agent.ts +19 -3
- package/corpus/core/src/agent/types.ts +1 -1
- package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
- package/corpus/core/src/cli/recap.ts +43 -34
- package/corpus/core/src/client/AssistantChat.tsx +27 -4
- package/corpus/core/src/client/PoweredByBadge.tsx +70 -17
- package/corpus/core/src/client/agent-chat-adapter.ts +5 -1
- package/corpus/core/src/client/guided-questions.tsx +11 -6
- package/corpus/core/src/client/i18n.tsx +10 -5
- package/corpus/core/src/client/sse-event-processor.ts +147 -5
- package/corpus/core/src/client/tool-display.ts +8 -0
- package/corpus/core/src/file-upload/builder.ts +40 -14
- package/corpus/core/src/file-upload/types.ts +3 -0
- package/corpus/core/src/integrations/a2a-continuation-processor.ts +49 -24
- package/corpus/core/src/mcp/builtin-tools.ts +3 -0
- package/corpus/core/src/mcp/org-directory.ts +66 -20
- package/corpus/core/src/notifications/channels.ts +235 -38
- package/corpus/core/src/scripts/call-agent.ts +7 -2
- package/corpus/core/src/server/analytics.ts +32 -6
- package/corpus/core/src/server/ssr-handler.ts +31 -12
- package/corpus/templates/analytics/AGENTS.md +21 -0
- package/corpus/templates/analytics/README.md +35 -0
- package/corpus/templates/analytics/actions/delete-analytics-alert-rule.ts +23 -0
- package/corpus/templates/analytics/actions/list-analytics-alert-rules.ts +20 -0
- package/corpus/templates/analytics/actions/run-analytics-alerts.ts +23 -0
- package/corpus/templates/analytics/actions/save-analytics-alert-rule.ts +69 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +161 -12
- package/corpus/templates/analytics/changelog/2026-07-01-analytics-can-alert-when-first-party-events-spike.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-01-session-replays-load-large-production-recordings-from-scoped.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-01-session-replays-play-back-in-production-instead-of-showing.md +6 -0
- package/corpus/templates/analytics/netlify.toml +3 -0
- package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +109 -0
- package/corpus/templates/analytics/server/db/schema.ts +55 -0
- package/corpus/templates/analytics/server/handlers/session-replay.ts +5 -2
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +71 -0
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +734 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +10 -3
- package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +54 -0
- package/corpus/templates/analytics/server/plugins/db.ts +100 -0
- package/corpus/templates/analytics/server/routes/api/analytics-alerts/run.post.ts +60 -0
- package/corpus/templates/assets/README.md +30 -8
- package/corpus/templates/brain/README.md +20 -552
- package/corpus/templates/calendar/README.md +22 -28
- package/corpus/templates/calendar/app/pages/BookingPage.tsx +14 -8
- package/corpus/templates/calendar/changelog/2026-07-01-meeting-invite-pages-have-cleaner-branding-a-black-dark-mo.md +6 -0
- package/corpus/templates/chat/README.md +32 -0
- package/corpus/templates/clips/README.md +37 -0
- package/corpus/templates/clips/actions/create-recording.ts +7 -2
- package/corpus/templates/clips/actions/finalize-recording.ts +11 -0
- package/corpus/templates/clips/actions/get-recording-player-data.ts +4 -2
- package/corpus/templates/clips/actions/import-loom-recording.ts +1 -0
- package/corpus/templates/clips/actions/lib/loom-video.ts +16 -0
- package/corpus/templates/clips/app/components/library/library-grid.tsx +18 -7
- package/corpus/templates/clips/app/components/library/recording-card.tsx +42 -5
- package/corpus/templates/clips/app/components/player/scrubber.tsx +2 -2
- package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +4 -3
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +39 -12
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +106 -2
- package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +1 -22
- package/corpus/templates/clips/app/routes/record.tsx +48 -8
- package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-loom-imports-finish-sooner-by-skipping-the-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-playback-recover-more-reliably-from-storage.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-desktop-recording-avoids-unsupported-browser-tab-capture.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-embedded-player-progress-is-easier-to-see-on-dark-videos.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-loom-imports-show-clear-guidance-when-loom-does-not-provide-a-downloadable-video.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-move-to-folder-now-opens-folder-choices-directly-instead-of.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-preserve-in-flight-streaming-uploads-when-the-streaming-kill.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-recording-controls-are-cleaner-and-the-countdown-uses-shadow.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-recordings-now-recover-when-final-upload-status-gets-lost.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +7 -224
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +64 -6
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +87 -5
- package/corpus/templates/clips/server/lib/streaming-upload-mode.ts +7 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +51 -1
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +4 -1
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +96 -1
- package/corpus/templates/clips/shared/finalize-recovery.ts +242 -0
- package/corpus/templates/content/AGENTS.md +7 -3
- package/corpus/templates/content/README.md +22 -34
- package/corpus/templates/content/actions/list-trashed-content-databases.ts +4 -0
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +58 -6
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +257 -24
- package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +41 -15
- package/corpus/templates/content/app/i18n/zh-TW.ts +25 -7
- package/corpus/templates/content/app/i18n-data.ts +278 -40
- package/corpus/templates/content/changelog/2026-06-30-database-once-again-creates-an-inline-database-in-the-curren.md +6 -0
- package/corpus/templates/content/shared/api.ts +1 -0
- package/corpus/templates/design/AGENTS.md +7 -6
- package/corpus/templates/design/README.md +33 -0
- package/corpus/templates/design/actions/delete-file.ts +86 -10
- package/corpus/templates/design/actions/get-design-snapshot.ts +13 -0
- package/corpus/templates/design/actions/present-design-variants.ts +11 -13
- package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
- package/corpus/templates/design/app/pages/DesignEditor.tsx +2 -2
- package/corpus/templates/design/changelog/2026-07-01-design-recovers-sooner-when-an-agent-gets-stuck-preparing-a-.md +6 -0
- package/corpus/templates/dispatch/README.md +34 -0
- package/corpus/templates/forms/README.md +34 -0
- package/corpus/templates/macros/README.md +28 -0
- package/corpus/templates/mail/README.md +35 -0
- package/corpus/templates/plan/README.md +30 -118
- package/corpus/templates/plan/app/global.css +1 -1
- package/corpus/templates/slides/README.md +34 -0
- package/corpus/templates/videos/README.md +22 -201
- package/dist/a2a/caller-auth.d.ts +1 -0
- package/dist/a2a/caller-auth.d.ts.map +1 -1
- package/dist/a2a/caller-auth.js +34 -8
- package/dist/a2a/caller-auth.js.map +1 -1
- package/dist/a2a/client.d.ts +4 -0
- package/dist/a2a/client.d.ts.map +1 -1
- package/dist/a2a/client.js +148 -58
- package/dist/a2a/client.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +19 -4
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/types.d.ts +1 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +1 -1
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/cli/recap.d.ts +3 -3
- package/dist/cli/recap.d.ts.map +1 -1
- package/dist/cli/recap.js +41 -34
- package/dist/cli/recap.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +28 -4
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/PoweredByBadge.d.ts +9 -3
- package/dist/client/PoweredByBadge.d.ts.map +1 -1
- package/dist/client/PoweredByBadge.js +47 -15
- package/dist/client/PoweredByBadge.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +5 -1
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/guided-questions.d.ts.map +1 -1
- package/dist/client/guided-questions.js +10 -6
- package/dist/client/guided-questions.js.map +1 -1
- package/dist/client/i18n.d.ts +1 -1
- package/dist/client/i18n.d.ts.map +1 -1
- package/dist/client/i18n.js +6 -2
- package/dist/client/i18n.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts +2 -0
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +121 -5
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/client/tool-display.d.ts.map +1 -1
- package/dist/client/tool-display.js +8 -0
- package/dist/client/tool-display.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +26 -8
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/file-upload/types.d.ts +5 -1
- package/dist/file-upload/types.d.ts.map +1 -1
- package/dist/file-upload/types.js.map +1 -1
- package/dist/integrations/a2a-continuation-processor.js +43 -18
- package/dist/integrations/a2a-continuation-processor.js.map +1 -1
- package/dist/mcp/builtin-tools.d.ts.map +1 -1
- package/dist/mcp/builtin-tools.js +3 -0
- package/dist/mcp/builtin-tools.js.map +1 -1
- package/dist/mcp/org-directory.d.ts.map +1 -1
- package/dist/mcp/org-directory.js +58 -20
- package/dist/mcp/org-directory.js.map +1 -1
- package/dist/notifications/channels.d.ts +10 -0
- package/dist/notifications/channels.d.ts.map +1 -1
- package/dist/notifications/channels.js +172 -20
- package/dist/notifications/channels.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/scripts/call-agent.d.ts.map +1 -1
- package/dist/scripts/call-agent.js +2 -2
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/analytics.d.ts +16 -0
- package/dist/server/analytics.d.ts.map +1 -1
- package/dist/server/analytics.js +30 -7
- package/dist/server/analytics.js.map +1 -1
- package/dist/server/ssr-handler.d.ts.map +1 -1
- package/dist/server/ssr-handler.js +28 -12
- package/dist/server/ssr-handler.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import {
|
|
3
|
+
getRequestOrgId,
|
|
4
|
+
getRequestUserEmail,
|
|
5
|
+
} from "@agent-native/core/server";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { listAnalyticsAlertRules } from "../server/lib/analytics-alerts";
|
|
9
|
+
|
|
10
|
+
export default defineAction({
|
|
11
|
+
description: "List reusable alert rules over first-party analytics events.",
|
|
12
|
+
schema: z.object({}),
|
|
13
|
+
http: { method: "GET" },
|
|
14
|
+
run: async () => {
|
|
15
|
+
const email = getRequestUserEmail();
|
|
16
|
+
if (!email) throw new Error("no authenticated user");
|
|
17
|
+
const orgId = getRequestOrgId() || null;
|
|
18
|
+
return listAnalyticsAlertRules({ email, orgId });
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import {
|
|
3
|
+
getRequestOrgId,
|
|
4
|
+
getRequestUserEmail,
|
|
5
|
+
} from "@agent-native/core/server";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { runAnalyticsAlertsOnce } from "../server/jobs/analytics-alerts";
|
|
9
|
+
|
|
10
|
+
export default defineAction({
|
|
11
|
+
description:
|
|
12
|
+
"Run one alert sweep for the current user's first-party analytics alert rules.",
|
|
13
|
+
schema: z.object({
|
|
14
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
15
|
+
}),
|
|
16
|
+
http: { method: "POST" },
|
|
17
|
+
run: async ({ limit }) => {
|
|
18
|
+
const email = getRequestUserEmail();
|
|
19
|
+
if (!email) throw new Error("no authenticated user");
|
|
20
|
+
const orgId = getRequestOrgId() || null;
|
|
21
|
+
return runAnalyticsAlertsOnce({ ownerEmail: email, orgId, limit });
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import {
|
|
3
|
+
getRequestOrgId,
|
|
4
|
+
getRequestUserEmail,
|
|
5
|
+
} from "@agent-native/core/server";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { saveAnalyticsAlertRule } from "../server/lib/analytics-alerts";
|
|
9
|
+
|
|
10
|
+
const filterSchema = z.object({
|
|
11
|
+
field: z
|
|
12
|
+
.string()
|
|
13
|
+
.describe(
|
|
14
|
+
"Analytics field path, e.g. event_name, app, template, properties.error_code, or context.url.",
|
|
15
|
+
),
|
|
16
|
+
op: z
|
|
17
|
+
.enum(["equals", "not_equals", "contains", "in", "exists"])
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Filter operator; defaults to equals."),
|
|
20
|
+
value: z.unknown().optional().describe("Value to compare against."),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export default defineAction({
|
|
24
|
+
description:
|
|
25
|
+
"Create or update a reusable alert rule over first-party analytics events.",
|
|
26
|
+
schema: z.object({
|
|
27
|
+
id: z.string().optional().describe("Existing alert rule ID to update"),
|
|
28
|
+
name: z.string().describe("Human-readable alert name"),
|
|
29
|
+
description: z.string().optional(),
|
|
30
|
+
eventName: z
|
|
31
|
+
.string()
|
|
32
|
+
.optional()
|
|
33
|
+
.nullable()
|
|
34
|
+
.describe("Optional exact event name to target before filters run."),
|
|
35
|
+
filters: z
|
|
36
|
+
.array(filterSchema)
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Generic event filters evaluated over columns/properties."),
|
|
39
|
+
thresholdMode: z
|
|
40
|
+
.enum(["event_count", "distinct_count"])
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Count matching events or distinct values."),
|
|
43
|
+
distinctBy: z
|
|
44
|
+
.string()
|
|
45
|
+
.optional()
|
|
46
|
+
.nullable()
|
|
47
|
+
.describe("Field path used when thresholdMode is distinct_count."),
|
|
48
|
+
threshold: z.number().int().min(1),
|
|
49
|
+
windowMinutes: z.number().int().min(1).max(1440),
|
|
50
|
+
cooldownMinutes: z.number().int().min(0).max(1440).optional(),
|
|
51
|
+
severity: z.enum(["warning", "critical"]).optional(),
|
|
52
|
+
channels: z
|
|
53
|
+
.array(z.string())
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Notification channels, e.g. inbox, email, slack, webhook."),
|
|
56
|
+
emailRecipients: z
|
|
57
|
+
.array(z.string().email())
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Email recipients passed to the generic email channel."),
|
|
60
|
+
enabled: z.boolean().optional(),
|
|
61
|
+
}),
|
|
62
|
+
http: { method: "POST" },
|
|
63
|
+
run: async (args) => {
|
|
64
|
+
const email = getRequestUserEmail();
|
|
65
|
+
if (!email) throw new Error("no authenticated user");
|
|
66
|
+
const orgId = getRequestOrgId() || null;
|
|
67
|
+
return saveAnalyticsAlertRule(args, { email, orgId });
|
|
68
|
+
},
|
|
69
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
appApiPath,
|
|
2
3
|
PromptComposer,
|
|
3
|
-
useActionQuery,
|
|
4
4
|
useSendToAgentChat,
|
|
5
5
|
useT,
|
|
6
6
|
} from "@agent-native/core/client";
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
IconRoute,
|
|
19
19
|
IconTimelineEvent,
|
|
20
20
|
} from "@tabler/icons-react";
|
|
21
|
+
import { useQuery } from "@tanstack/react-query";
|
|
21
22
|
import {
|
|
22
23
|
type ReactNode,
|
|
23
24
|
useCallback,
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
TooltipProvider,
|
|
43
44
|
TooltipTrigger,
|
|
44
45
|
} from "@/components/ui/tooltip";
|
|
46
|
+
import { getIdToken } from "@/lib/auth";
|
|
45
47
|
import { cn } from "@/lib/utils";
|
|
46
48
|
|
|
47
49
|
type SessionRecordingSummary = {
|
|
@@ -83,7 +85,20 @@ type ReplayChunkEvents = {
|
|
|
83
85
|
unavailable?: boolean;
|
|
84
86
|
};
|
|
85
87
|
|
|
86
|
-
type
|
|
88
|
+
type SessionReplayManifestResponse = {
|
|
89
|
+
recording: SessionRecordingSummary;
|
|
90
|
+
chunks: Array<{
|
|
91
|
+
seq: number;
|
|
92
|
+
checksum: string;
|
|
93
|
+
byteLength: number;
|
|
94
|
+
eventCount: number;
|
|
95
|
+
startedAt: string | null;
|
|
96
|
+
endedAt: string | null;
|
|
97
|
+
bytesPath: string;
|
|
98
|
+
}>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type SessionReplayPlaybackResponse = {
|
|
87
102
|
recording: SessionRecordingSummary;
|
|
88
103
|
chunks: ReplayChunkEvents[];
|
|
89
104
|
eventCount: number;
|
|
@@ -122,6 +137,8 @@ const SPEED_OPTIONS = [0.5, 1, 2, 4, 8];
|
|
|
122
137
|
const SKIP_STEP_MS = 5000;
|
|
123
138
|
const MIN_IDLE_SKIP_MS = 8000;
|
|
124
139
|
const IDLE_EDGE_PAD_MS = 1200;
|
|
140
|
+
const REPLAY_CHUNK_FETCH_CONCURRENCY = 6;
|
|
141
|
+
const REPLAY_CHUNK_UNAVAILABLE_MESSAGE = "Session replay chunk is unavailable";
|
|
125
142
|
|
|
126
143
|
const RRWEB_EVENT_TYPE = {
|
|
127
144
|
FullSnapshot: 2,
|
|
@@ -155,12 +172,7 @@ export default function SessionDetailPage() {
|
|
|
155
172
|
const t = useT();
|
|
156
173
|
const { recordingId = "" } = useParams();
|
|
157
174
|
const { codeRequiredDialog } = useSendToAgentChat();
|
|
158
|
-
const { data, isLoading, error } =
|
|
159
|
-
useActionQuery<SessionReplayEventsResponse>(
|
|
160
|
-
"get-session-replay-events",
|
|
161
|
-
{ recordingId, limit: 10000 },
|
|
162
|
-
{ enabled: Boolean(recordingId), staleTime: 30_000 },
|
|
163
|
-
);
|
|
175
|
+
const { data, isLoading, error } = useSessionReplayPlayback(recordingId);
|
|
164
176
|
const recording = data?.recording;
|
|
165
177
|
|
|
166
178
|
return (
|
|
@@ -196,7 +208,9 @@ export default function SessionDetailPage() {
|
|
|
196
208
|
<Card>
|
|
197
209
|
<CardContent className="flex items-center gap-3 p-6 text-sm text-destructive">
|
|
198
210
|
<IconExclamationCircle className="h-5 w-5" />
|
|
199
|
-
{t("sessions.loadFailed", {
|
|
211
|
+
{t("sessions.loadFailed", {
|
|
212
|
+
message: error instanceof Error ? error.message : String(error),
|
|
213
|
+
})}
|
|
200
214
|
</CardContent>
|
|
201
215
|
</Card>
|
|
202
216
|
) : isLoading ? (
|
|
@@ -268,7 +282,7 @@ function AskSessionPopover({
|
|
|
268
282
|
function ReplayWorkbench({
|
|
269
283
|
response,
|
|
270
284
|
}: {
|
|
271
|
-
response:
|
|
285
|
+
response: SessionReplayPlaybackResponse;
|
|
272
286
|
}) {
|
|
273
287
|
const events = useReplayEvents(response);
|
|
274
288
|
const markers = useMemo(() => buildReplayMarkers(events), [events]);
|
|
@@ -318,7 +332,7 @@ function ReplayPlayer({
|
|
|
318
332
|
}: {
|
|
319
333
|
events: AnyReplayEvent[];
|
|
320
334
|
markers: ReplayMarker[];
|
|
321
|
-
response:
|
|
335
|
+
response: SessionReplayPlaybackResponse;
|
|
322
336
|
onTimeUpdate: (ms: number) => void;
|
|
323
337
|
registerSeek: (seek: (ms: number, autoplay?: boolean) => void) => void;
|
|
324
338
|
}) {
|
|
@@ -956,8 +970,143 @@ function DetailSkeleton() {
|
|
|
956
970
|
);
|
|
957
971
|
}
|
|
958
972
|
|
|
973
|
+
function useSessionReplayPlayback(recordingId: string) {
|
|
974
|
+
return useQuery({
|
|
975
|
+
queryKey: ["session-replay-playback", recordingId],
|
|
976
|
+
enabled: Boolean(recordingId),
|
|
977
|
+
staleTime: 30_000,
|
|
978
|
+
gcTime: 60_000,
|
|
979
|
+
queryFn: () => fetchSessionReplayPlayback(recordingId),
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export async function fetchSessionReplayPlayback(
|
|
984
|
+
recordingId: string,
|
|
985
|
+
): Promise<SessionReplayPlaybackResponse> {
|
|
986
|
+
const manifest = await fetchReplayManifest(recordingId);
|
|
987
|
+
const chunks = await fetchReplayChunks(manifest.chunks);
|
|
988
|
+
const unavailableChunks = chunks.filter((chunk) => chunk.unavailable).length;
|
|
989
|
+
const eventCount = chunks.reduce(
|
|
990
|
+
(sum, chunk) => sum + chunk.events.length,
|
|
991
|
+
0,
|
|
992
|
+
);
|
|
993
|
+
return {
|
|
994
|
+
recording: manifest.recording,
|
|
995
|
+
chunks,
|
|
996
|
+
eventCount,
|
|
997
|
+
truncated: false,
|
|
998
|
+
unavailableChunks,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async function fetchReplayManifest(
|
|
1003
|
+
recordingId: string,
|
|
1004
|
+
): Promise<SessionReplayManifestResponse> {
|
|
1005
|
+
const response = await fetchReplayApi(
|
|
1006
|
+
`/api/session-replay/recordings/${encodeURIComponent(
|
|
1007
|
+
recordingId,
|
|
1008
|
+
)}/manifest`,
|
|
1009
|
+
);
|
|
1010
|
+
if (!response.ok) throw await replayFetchError(response);
|
|
1011
|
+
return (await response.json()) as SessionReplayManifestResponse;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function fetchReplayChunks(
|
|
1015
|
+
chunks: SessionReplayManifestResponse["chunks"],
|
|
1016
|
+
): Promise<ReplayChunkEvents[]> {
|
|
1017
|
+
const results = new Array<ReplayChunkEvents>(chunks.length);
|
|
1018
|
+
let nextIndex = 0;
|
|
1019
|
+
|
|
1020
|
+
async function worker() {
|
|
1021
|
+
while (nextIndex < chunks.length) {
|
|
1022
|
+
const index = nextIndex;
|
|
1023
|
+
nextIndex += 1;
|
|
1024
|
+
results[index] = await fetchReplayChunk(chunks[index]);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
await Promise.all(
|
|
1029
|
+
Array.from(
|
|
1030
|
+
{ length: Math.min(REPLAY_CHUNK_FETCH_CONCURRENCY, chunks.length) },
|
|
1031
|
+
worker,
|
|
1032
|
+
),
|
|
1033
|
+
);
|
|
1034
|
+
return results;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function fetchReplayChunk(
|
|
1038
|
+
chunk: SessionReplayManifestResponse["chunks"][number],
|
|
1039
|
+
): Promise<ReplayChunkEvents> {
|
|
1040
|
+
const response = await fetchReplayApi(chunk.bytesPath);
|
|
1041
|
+
if (!response.ok) {
|
|
1042
|
+
const error = await replayFetchError(response);
|
|
1043
|
+
if (isUnavailableReplayChunk(response, error)) {
|
|
1044
|
+
return replayUnavailableChunk(chunk);
|
|
1045
|
+
}
|
|
1046
|
+
throw error;
|
|
1047
|
+
}
|
|
1048
|
+
const payload = await response.json();
|
|
1049
|
+
return {
|
|
1050
|
+
seq: chunk.seq,
|
|
1051
|
+
checksum: chunk.checksum,
|
|
1052
|
+
byteLength: chunk.byteLength,
|
|
1053
|
+
eventCount: chunk.eventCount,
|
|
1054
|
+
events: replayPayloadEvents(payload),
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function isUnavailableReplayChunk(response: Response, error: Error): boolean {
|
|
1059
|
+
return (
|
|
1060
|
+
response.status === 404 &&
|
|
1061
|
+
error.message === REPLAY_CHUNK_UNAVAILABLE_MESSAGE
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function replayUnavailableChunk(
|
|
1066
|
+
chunk: SessionReplayManifestResponse["chunks"][number],
|
|
1067
|
+
): ReplayChunkEvents {
|
|
1068
|
+
return {
|
|
1069
|
+
seq: chunk.seq,
|
|
1070
|
+
checksum: chunk.checksum,
|
|
1071
|
+
byteLength: chunk.byteLength,
|
|
1072
|
+
eventCount: chunk.eventCount,
|
|
1073
|
+
events: [],
|
|
1074
|
+
unavailable: true,
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
async function fetchReplayApi(path: string): Promise<Response> {
|
|
1079
|
+
const token = await getIdToken();
|
|
1080
|
+
return fetch(appApiPath(path), {
|
|
1081
|
+
headers: {
|
|
1082
|
+
Accept: "application/json",
|
|
1083
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
1084
|
+
},
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async function replayFetchError(response: Response): Promise<Error> {
|
|
1089
|
+
try {
|
|
1090
|
+
const payload = await response.json();
|
|
1091
|
+
if (isRecord(payload) && typeof payload.error === "string") {
|
|
1092
|
+
return new Error(payload.error);
|
|
1093
|
+
}
|
|
1094
|
+
} catch {
|
|
1095
|
+
// Fall through to the status text.
|
|
1096
|
+
}
|
|
1097
|
+
return new Error(response.statusText || `HTTP ${response.status}`);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export function replayPayloadEvents(payload: unknown): unknown[] {
|
|
1101
|
+
if (Array.isArray(payload)) return payload;
|
|
1102
|
+
if (isRecord(payload) && Array.isArray(payload.events)) {
|
|
1103
|
+
return payload.events;
|
|
1104
|
+
}
|
|
1105
|
+
return payload ? [payload] : [];
|
|
1106
|
+
}
|
|
1107
|
+
|
|
959
1108
|
function useReplayEvents(
|
|
960
|
-
response:
|
|
1109
|
+
response: SessionReplayPlaybackResponse,
|
|
961
1110
|
): AnyReplayEvent[] {
|
|
962
1111
|
return useMemo(
|
|
963
1112
|
() =>
|
|
@@ -12,6 +12,10 @@ const SCHEDULED_NAME = "dashboard-report-cron";
|
|
|
12
12
|
const WORKER_NAME = "dashboard-report-sweep-background";
|
|
13
13
|
const ROUTE_PATH = "/api/dashboard-reports/run";
|
|
14
14
|
const SCHEDULE = "*/15 * * * *";
|
|
15
|
+
const ALERT_SCHEDULED_NAME = "analytics-alert-cron";
|
|
16
|
+
const ALERT_WORKER_NAME = "analytics-alert-sweep-background";
|
|
17
|
+
const ALERT_ROUTE_PATH = "/api/analytics-alerts/run";
|
|
18
|
+
const ALERT_SCHEDULE = "*/5 * * * *";
|
|
15
19
|
|
|
16
20
|
function ensureDir(dir: string) {
|
|
17
21
|
mkdirSync(dir, { recursive: true });
|
|
@@ -116,6 +120,105 @@ export const config = {
|
|
|
116
120
|
writeFileSync(path.join(dest, `${WORKER_NAME}.mjs`), source);
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
function emitAlertScheduledTrigger(token: string) {
|
|
124
|
+
const dest = path.join(FUNCTIONS_DIR, ALERT_SCHEDULED_NAME);
|
|
125
|
+
rmSync(dest, { recursive: true, force: true });
|
|
126
|
+
ensureDir(dest);
|
|
127
|
+
|
|
128
|
+
const source = `const WORKER_PATH = "/.netlify/functions/${ALERT_WORKER_NAME}";
|
|
129
|
+
const CRON_TOKEN = ${JSON.stringify(token)};
|
|
130
|
+
|
|
131
|
+
function siteOrigin(request) {
|
|
132
|
+
const configured = process.env.URL || process.env.DEPLOY_URL;
|
|
133
|
+
if (configured) return configured;
|
|
134
|
+
const url = new URL(request.url);
|
|
135
|
+
return url.origin;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default async function handler(request) {
|
|
139
|
+
const url = new URL(WORKER_PATH, siteOrigin(request));
|
|
140
|
+
const response = await fetch(url.toString(), {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: {
|
|
143
|
+
"content-type": "application/json",
|
|
144
|
+
"x-agent-native-analytics-alert-cron": CRON_TOKEN,
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify({ scheduled: true }),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!response.ok && response.status !== 202) {
|
|
150
|
+
console.error(
|
|
151
|
+
"[analytics-alert-cron] Background sweep trigger failed:",
|
|
152
|
+
response.status,
|
|
153
|
+
await response.text().catch(() => ""),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const config = {
|
|
159
|
+
name: "analytics alert cron trigger",
|
|
160
|
+
generator: "agent-native analytics build",
|
|
161
|
+
schedule: ${JSON.stringify(ALERT_SCHEDULE)},
|
|
162
|
+
};
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
writeFileSync(path.join(dest, `${ALERT_SCHEDULED_NAME}.mjs`), source);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function emitAlertBackgroundWorker(token: string) {
|
|
169
|
+
const dest = path.join(FUNCTIONS_DIR, ALERT_WORKER_NAME);
|
|
170
|
+
rmSync(dest, { recursive: true, force: true });
|
|
171
|
+
cpSync(SERVER_DIR, dest, { recursive: true });
|
|
172
|
+
rmSync(path.join(dest, "server.mjs"), { force: true });
|
|
173
|
+
|
|
174
|
+
const source = `globalThis.__AGENT_NATIVE_ANALYTICS_ALERT_SCHEDULED_RUNTIME__ = true;
|
|
175
|
+
|
|
176
|
+
const CRON_TOKEN = ${JSON.stringify(token)};
|
|
177
|
+
const ROUTE_PATH = ${JSON.stringify(ALERT_ROUTE_PATH)};
|
|
178
|
+
let cachedHandler;
|
|
179
|
+
|
|
180
|
+
function timingSafeEquals(a, b) {
|
|
181
|
+
if (a.length !== b.length) return false;
|
|
182
|
+
let diff = 0;
|
|
183
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
184
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
185
|
+
}
|
|
186
|
+
return diff === 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default async function handler(request, context) {
|
|
190
|
+
const token = request.headers.get("x-agent-native-analytics-alert-cron") || "";
|
|
191
|
+
if (!timingSafeEquals(token, CRON_TOKEN)) {
|
|
192
|
+
return new Response("Unauthorized", { status: 401 });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
cachedHandler ??= (await import("./main.mjs")).default;
|
|
196
|
+
const url = new URL(request.url);
|
|
197
|
+
url.pathname = ROUTE_PATH;
|
|
198
|
+
url.search = "";
|
|
199
|
+
|
|
200
|
+
const rewritten = new Request(url.toString(), {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
203
|
+
body: JSON.stringify({ scheduled: true }),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return await cachedHandler(rewritten, context);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const config = {
|
|
210
|
+
name: "analytics alert background sweep",
|
|
211
|
+
generator: "agent-native analytics build",
|
|
212
|
+
background: true,
|
|
213
|
+
nodeBundler: "none",
|
|
214
|
+
includedFiles: ["**"],
|
|
215
|
+
preferStatic: false,
|
|
216
|
+
};
|
|
217
|
+
`;
|
|
218
|
+
|
|
219
|
+
writeFileSync(path.join(dest, `${ALERT_WORKER_NAME}.mjs`), source);
|
|
220
|
+
}
|
|
221
|
+
|
|
119
222
|
function isDirectRun(): boolean {
|
|
120
223
|
const entrypoint = process.argv[1];
|
|
121
224
|
return Boolean(
|
|
@@ -133,11 +236,17 @@ function main(): void {
|
|
|
133
236
|
}
|
|
134
237
|
|
|
135
238
|
const token = randomBytes(32).toString("hex");
|
|
239
|
+
const alertToken = randomBytes(32).toString("hex");
|
|
136
240
|
emitScheduledTrigger(token);
|
|
137
241
|
emitBackgroundWorker(token);
|
|
242
|
+
emitAlertScheduledTrigger(alertToken);
|
|
243
|
+
emitAlertBackgroundWorker(alertToken);
|
|
138
244
|
console.log(
|
|
139
245
|
`[dashboard-report-cron] Emitted Netlify scheduled trigger "${SCHEDULED_NAME}" (${SCHEDULE}) and background worker "${WORKER_NAME}".`,
|
|
140
246
|
);
|
|
247
|
+
console.log(
|
|
248
|
+
`[analytics-alert-cron] Emitted Netlify scheduled trigger "${ALERT_SCHEDULED_NAME}" (${ALERT_SCHEDULE}) and background worker "${ALERT_WORKER_NAME}".`,
|
|
249
|
+
);
|
|
141
250
|
}
|
|
142
251
|
|
|
143
252
|
if (isDirectRun()) {
|
|
@@ -179,6 +179,61 @@ export const analyticsEvents = table("analytics_events", {
|
|
|
179
179
|
orgId: text("org_id"),
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Generic alert rules over first-party analytics events. Rules are owned by a
|
|
184
|
+
* user/org but can target any app, template, event name, or event property.
|
|
185
|
+
*/
|
|
186
|
+
export const analyticsAlertRules = table("analytics_alert_rules", {
|
|
187
|
+
id: text("id").primaryKey(),
|
|
188
|
+
name: text("name").notNull(),
|
|
189
|
+
description: text("description").notNull().default(""),
|
|
190
|
+
eventName: text("event_name"),
|
|
191
|
+
filters: text("filters").notNull().default("[]"),
|
|
192
|
+
thresholdMode: text("threshold_mode", {
|
|
193
|
+
enum: ["event_count", "distinct_count"],
|
|
194
|
+
})
|
|
195
|
+
.notNull()
|
|
196
|
+
.default("event_count"),
|
|
197
|
+
distinctBy: text("distinct_by"),
|
|
198
|
+
threshold: integer("threshold").notNull().default(1),
|
|
199
|
+
windowMinutes: integer("window_minutes").notNull().default(10),
|
|
200
|
+
cooldownMinutes: integer("cooldown_minutes").notNull().default(30),
|
|
201
|
+
severity: text("severity", { enum: ["warning", "critical"] })
|
|
202
|
+
.notNull()
|
|
203
|
+
.default("warning"),
|
|
204
|
+
channels: text("channels").notNull().default('["inbox"]'),
|
|
205
|
+
emailRecipients: text("email_recipients").notNull().default("[]"),
|
|
206
|
+
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
207
|
+
lastEvaluatedAt: text("last_evaluated_at"),
|
|
208
|
+
lastTriggeredAt: text("last_triggered_at"),
|
|
209
|
+
lastStatus: text("last_status", {
|
|
210
|
+
enum: ["ok", "triggered", "cooldown", "error"],
|
|
211
|
+
}),
|
|
212
|
+
lastError: text("last_error"),
|
|
213
|
+
createdAt: text("created_at").notNull().default(now()),
|
|
214
|
+
updatedAt: text("updated_at").notNull().default(now()),
|
|
215
|
+
ownerEmail: text("owner_email").notNull().default("local@localhost"),
|
|
216
|
+
orgId: text("org_id"),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
export const analyticsAlertIncidents = table("analytics_alert_incidents", {
|
|
220
|
+
id: text("id").primaryKey(),
|
|
221
|
+
ruleId: text("rule_id").notNull(),
|
|
222
|
+
triggeredAt: text("triggered_at").notNull(),
|
|
223
|
+
windowStart: text("window_start").notNull(),
|
|
224
|
+
windowEnd: text("window_end").notNull(),
|
|
225
|
+
threshold: integer("threshold").notNull(),
|
|
226
|
+
observedValue: integer("observed_value").notNull(),
|
|
227
|
+
eventCount: integer("event_count").notNull(),
|
|
228
|
+
severity: text("severity", { enum: ["warning", "critical"] }).notNull(),
|
|
229
|
+
channels: text("channels").notNull().default("[]"),
|
|
230
|
+
sampleEvents: text("sample_events").notNull().default("[]"),
|
|
231
|
+
notificationId: text("notification_id"),
|
|
232
|
+
createdAt: text("created_at").notNull().default(now()),
|
|
233
|
+
ownerEmail: text("owner_email").notNull().default("local@localhost"),
|
|
234
|
+
orgId: text("org_id"),
|
|
235
|
+
});
|
|
236
|
+
|
|
182
237
|
/**
|
|
183
238
|
* Session replay summaries recorded through the first-party analytics replay
|
|
184
239
|
* endpoint. Raw replay chunks live in session_replay_chunks and are only read
|
|
@@ -355,12 +355,15 @@ export const handleSessionReplayChunkBytes = defineEventHandler(
|
|
|
355
355
|
userEmail: ctx.userEmail,
|
|
356
356
|
orgId: ctx.orgId ?? null,
|
|
357
357
|
});
|
|
358
|
+
// Serve decompressed JSON and let the platform negotiate wire
|
|
359
|
+
// compression. Manually returning a pre-gzipped body with a
|
|
360
|
+
// `Content-Encoding: gzip` header corrupted replay downloads on
|
|
361
|
+
// serverless hosts and left playback blank in production.
|
|
358
362
|
setResponseHeader(event, "Content-Type", "application/json");
|
|
359
|
-
setResponseHeader(event, "Content-Encoding", "gzip");
|
|
360
363
|
setResponseHeader(event, "Cache-Control", "no-store");
|
|
361
364
|
setResponseHeader(event, "X-Session-Replay-Seq", String(result.seq));
|
|
362
365
|
setResponseHeader(event, "X-Session-Replay-Checksum", result.checksum);
|
|
363
|
-
return result.
|
|
366
|
+
return result.json;
|
|
364
367
|
} catch (error: any) {
|
|
365
368
|
setResponseStatus(event, statusFromError(error));
|
|
366
369
|
return { error: messageFromError(error) };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
evaluateAndNotifyAnalyticsAlertRule,
|
|
3
|
+
listEnabledAnalyticsAlertRules,
|
|
4
|
+
markAnalyticsAlertRuleError,
|
|
5
|
+
} from "../lib/analytics-alerts";
|
|
6
|
+
|
|
7
|
+
let running = false;
|
|
8
|
+
const DEFAULT_MAX_RULES_PER_SWEEP = 100;
|
|
9
|
+
|
|
10
|
+
function maxRulesPerSweep(input?: number): number {
|
|
11
|
+
if (input) return Math.max(1, Math.min(500, Math.floor(input)));
|
|
12
|
+
const raw = process.env.ANALYTICS_ALERT_SWEEP_LIMIT?.trim();
|
|
13
|
+
if (!raw) return DEFAULT_MAX_RULES_PER_SWEEP;
|
|
14
|
+
const parsed = Number.parseInt(raw, 10);
|
|
15
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
16
|
+
? parsed
|
|
17
|
+
: DEFAULT_MAX_RULES_PER_SWEEP;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run one analytics alert sweep. Exported for deployment-specific scheduled
|
|
22
|
+
* functions that should not rely on a long-lived Node process.
|
|
23
|
+
*/
|
|
24
|
+
export async function runAnalyticsAlertsOnce(
|
|
25
|
+
options: {
|
|
26
|
+
ownerEmail?: string;
|
|
27
|
+
orgId?: string | null;
|
|
28
|
+
limit?: number;
|
|
29
|
+
} = {},
|
|
30
|
+
): Promise<{
|
|
31
|
+
processed: number;
|
|
32
|
+
triggered: number;
|
|
33
|
+
failed: number;
|
|
34
|
+
remaining: number;
|
|
35
|
+
}> {
|
|
36
|
+
if (running) return { processed: 0, triggered: 0, failed: 0, remaining: 0 };
|
|
37
|
+
running = true;
|
|
38
|
+
let processed = 0;
|
|
39
|
+
let triggered = 0;
|
|
40
|
+
let failed = 0;
|
|
41
|
+
let remaining = 0;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const sweepLimit = maxRulesPerSweep(options.limit);
|
|
45
|
+
const rules = await listEnabledAnalyticsAlertRules({
|
|
46
|
+
limit: sweepLimit,
|
|
47
|
+
ownerEmail: options.ownerEmail,
|
|
48
|
+
orgId: options.orgId,
|
|
49
|
+
});
|
|
50
|
+
remaining = rules.length >= sweepLimit ? 1 : 0;
|
|
51
|
+
|
|
52
|
+
for (const rule of rules) {
|
|
53
|
+
processed++;
|
|
54
|
+
try {
|
|
55
|
+
const result = await evaluateAndNotifyAnalyticsAlertRule(rule);
|
|
56
|
+
if (result.status === "triggered") triggered++;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
failed++;
|
|
59
|
+
await markAnalyticsAlertRuleError(rule.id, err).catch(() => {});
|
|
60
|
+
console.error(
|
|
61
|
+
`[analytics-alerts] Rule ${rule.id} (${rule.name}) failed:`,
|
|
62
|
+
err,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
running = false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { processed, triggered, failed, remaining };
|
|
71
|
+
}
|