@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.
Files changed (193) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/caller-auth.ts +37 -8
  5. package/corpus/core/src/a2a/client.ts +178 -67
  6. package/corpus/core/src/agent/production-agent.ts +19 -3
  7. package/corpus/core/src/agent/types.ts +1 -1
  8. package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
  9. package/corpus/core/src/cli/recap.ts +43 -34
  10. package/corpus/core/src/client/AssistantChat.tsx +27 -4
  11. package/corpus/core/src/client/PoweredByBadge.tsx +70 -17
  12. package/corpus/core/src/client/agent-chat-adapter.ts +5 -1
  13. package/corpus/core/src/client/guided-questions.tsx +11 -6
  14. package/corpus/core/src/client/i18n.tsx +10 -5
  15. package/corpus/core/src/client/sse-event-processor.ts +147 -5
  16. package/corpus/core/src/client/tool-display.ts +8 -0
  17. package/corpus/core/src/file-upload/builder.ts +40 -14
  18. package/corpus/core/src/file-upload/types.ts +3 -0
  19. package/corpus/core/src/integrations/a2a-continuation-processor.ts +49 -24
  20. package/corpus/core/src/mcp/builtin-tools.ts +3 -0
  21. package/corpus/core/src/mcp/org-directory.ts +66 -20
  22. package/corpus/core/src/notifications/channels.ts +235 -38
  23. package/corpus/core/src/scripts/call-agent.ts +7 -2
  24. package/corpus/core/src/server/analytics.ts +32 -6
  25. package/corpus/core/src/server/ssr-handler.ts +31 -12
  26. package/corpus/templates/analytics/AGENTS.md +21 -0
  27. package/corpus/templates/analytics/README.md +35 -0
  28. package/corpus/templates/analytics/actions/delete-analytics-alert-rule.ts +23 -0
  29. package/corpus/templates/analytics/actions/list-analytics-alert-rules.ts +20 -0
  30. package/corpus/templates/analytics/actions/run-analytics-alerts.ts +23 -0
  31. package/corpus/templates/analytics/actions/save-analytics-alert-rule.ts +69 -0
  32. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +161 -12
  33. package/corpus/templates/analytics/changelog/2026-07-01-analytics-can-alert-when-first-party-events-spike.md +6 -0
  34. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-load-large-production-recordings-from-scoped.md +6 -0
  35. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-play-back-in-production-instead-of-showing.md +6 -0
  36. package/corpus/templates/analytics/netlify.toml +3 -0
  37. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +109 -0
  38. package/corpus/templates/analytics/server/db/schema.ts +55 -0
  39. package/corpus/templates/analytics/server/handlers/session-replay.ts +5 -2
  40. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +71 -0
  41. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +734 -0
  42. package/corpus/templates/analytics/server/lib/session-replay.ts +10 -3
  43. package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +54 -0
  44. package/corpus/templates/analytics/server/plugins/db.ts +100 -0
  45. package/corpus/templates/analytics/server/routes/api/analytics-alerts/run.post.ts +60 -0
  46. package/corpus/templates/assets/README.md +30 -8
  47. package/corpus/templates/brain/README.md +20 -552
  48. package/corpus/templates/calendar/README.md +22 -28
  49. package/corpus/templates/calendar/app/pages/BookingPage.tsx +14 -8
  50. package/corpus/templates/calendar/changelog/2026-07-01-meeting-invite-pages-have-cleaner-branding-a-black-dark-mo.md +6 -0
  51. package/corpus/templates/chat/README.md +32 -0
  52. package/corpus/templates/clips/README.md +37 -0
  53. package/corpus/templates/clips/actions/create-recording.ts +7 -2
  54. package/corpus/templates/clips/actions/finalize-recording.ts +11 -0
  55. package/corpus/templates/clips/actions/get-recording-player-data.ts +4 -2
  56. package/corpus/templates/clips/actions/import-loom-recording.ts +1 -0
  57. package/corpus/templates/clips/actions/lib/loom-video.ts +16 -0
  58. package/corpus/templates/clips/app/components/library/library-grid.tsx +18 -7
  59. package/corpus/templates/clips/app/components/library/recording-card.tsx +42 -5
  60. package/corpus/templates/clips/app/components/player/scrubber.tsx +2 -2
  61. package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +4 -3
  62. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +39 -12
  63. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +106 -2
  64. package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +1 -22
  65. package/corpus/templates/clips/app/routes/record.tsx +48 -8
  66. package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-loom-imports-finish-sooner-by-skipping-the-.md +6 -0
  67. package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-playback-recover-more-reliably-from-storage.md +6 -0
  68. package/corpus/templates/clips/changelog/2026-07-01-desktop-recording-avoids-unsupported-browser-tab-capture.md +6 -0
  69. package/corpus/templates/clips/changelog/2026-07-01-embedded-player-progress-is-easier-to-see-on-dark-videos.md +6 -0
  70. package/corpus/templates/clips/changelog/2026-07-01-loom-imports-show-clear-guidance-when-loom-does-not-provide-a-downloadable-video.md +6 -0
  71. package/corpus/templates/clips/changelog/2026-07-01-move-to-folder-now-opens-folder-choices-directly-instead-of.md +6 -0
  72. package/corpus/templates/clips/changelog/2026-07-01-preserve-in-flight-streaming-uploads-when-the-streaming-kill.md +6 -0
  73. package/corpus/templates/clips/changelog/2026-07-01-recording-controls-are-cleaner-and-the-countdown-uses-shadow.md +6 -0
  74. package/corpus/templates/clips/changelog/2026-07-01-recordings-now-recover-when-final-upload-status-gets-lost.md +6 -0
  75. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  76. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +7 -224
  77. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +64 -6
  78. package/corpus/templates/clips/desktop/src/lib/recorder.ts +87 -5
  79. package/corpus/templates/clips/server/lib/streaming-upload-mode.ts +7 -0
  80. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +51 -1
  81. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +4 -1
  82. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +96 -1
  83. package/corpus/templates/clips/shared/finalize-recovery.ts +242 -0
  84. package/corpus/templates/content/AGENTS.md +7 -3
  85. package/corpus/templates/content/README.md +22 -34
  86. package/corpus/templates/content/actions/list-trashed-content-databases.ts +4 -0
  87. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +58 -6
  88. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +257 -24
  89. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +41 -15
  90. package/corpus/templates/content/app/i18n/zh-TW.ts +25 -7
  91. package/corpus/templates/content/app/i18n-data.ts +278 -40
  92. package/corpus/templates/content/changelog/2026-06-30-database-once-again-creates-an-inline-database-in-the-curren.md +6 -0
  93. package/corpus/templates/content/shared/api.ts +1 -0
  94. package/corpus/templates/design/AGENTS.md +7 -6
  95. package/corpus/templates/design/README.md +33 -0
  96. package/corpus/templates/design/actions/delete-file.ts +86 -10
  97. package/corpus/templates/design/actions/get-design-snapshot.ts +13 -0
  98. package/corpus/templates/design/actions/present-design-variants.ts +11 -13
  99. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  100. package/corpus/templates/design/app/pages/DesignEditor.tsx +2 -2
  101. package/corpus/templates/design/changelog/2026-07-01-design-recovers-sooner-when-an-agent-gets-stuck-preparing-a-.md +6 -0
  102. package/corpus/templates/dispatch/README.md +34 -0
  103. package/corpus/templates/forms/README.md +34 -0
  104. package/corpus/templates/macros/README.md +28 -0
  105. package/corpus/templates/mail/README.md +35 -0
  106. package/corpus/templates/plan/README.md +30 -118
  107. package/corpus/templates/plan/app/global.css +1 -1
  108. package/corpus/templates/slides/README.md +34 -0
  109. package/corpus/templates/videos/README.md +22 -201
  110. package/dist/a2a/caller-auth.d.ts +1 -0
  111. package/dist/a2a/caller-auth.d.ts.map +1 -1
  112. package/dist/a2a/caller-auth.js +34 -8
  113. package/dist/a2a/caller-auth.js.map +1 -1
  114. package/dist/a2a/client.d.ts +4 -0
  115. package/dist/a2a/client.d.ts.map +1 -1
  116. package/dist/a2a/client.js +148 -58
  117. package/dist/a2a/client.js.map +1 -1
  118. package/dist/agent/production-agent.d.ts.map +1 -1
  119. package/dist/agent/production-agent.js +19 -4
  120. package/dist/agent/production-agent.js.map +1 -1
  121. package/dist/agent/types.d.ts +1 -0
  122. package/dist/agent/types.d.ts.map +1 -1
  123. package/dist/agent/types.js.map +1 -1
  124. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  125. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  126. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  127. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  128. package/dist/cli/recap.d.ts +3 -3
  129. package/dist/cli/recap.d.ts.map +1 -1
  130. package/dist/cli/recap.js +41 -34
  131. package/dist/cli/recap.js.map +1 -1
  132. package/dist/client/AssistantChat.d.ts.map +1 -1
  133. package/dist/client/AssistantChat.js +28 -4
  134. package/dist/client/AssistantChat.js.map +1 -1
  135. package/dist/client/PoweredByBadge.d.ts +9 -3
  136. package/dist/client/PoweredByBadge.d.ts.map +1 -1
  137. package/dist/client/PoweredByBadge.js +47 -15
  138. package/dist/client/PoweredByBadge.js.map +1 -1
  139. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  140. package/dist/client/agent-chat-adapter.js +5 -1
  141. package/dist/client/agent-chat-adapter.js.map +1 -1
  142. package/dist/client/guided-questions.d.ts.map +1 -1
  143. package/dist/client/guided-questions.js +10 -6
  144. package/dist/client/guided-questions.js.map +1 -1
  145. package/dist/client/i18n.d.ts +1 -1
  146. package/dist/client/i18n.d.ts.map +1 -1
  147. package/dist/client/i18n.js +6 -2
  148. package/dist/client/i18n.js.map +1 -1
  149. package/dist/client/sse-event-processor.d.ts +2 -0
  150. package/dist/client/sse-event-processor.d.ts.map +1 -1
  151. package/dist/client/sse-event-processor.js +121 -5
  152. package/dist/client/sse-event-processor.js.map +1 -1
  153. package/dist/client/tool-display.d.ts.map +1 -1
  154. package/dist/client/tool-display.js +8 -0
  155. package/dist/client/tool-display.js.map +1 -1
  156. package/dist/collab/awareness.d.ts +2 -2
  157. package/dist/collab/awareness.d.ts.map +1 -1
  158. package/dist/collab/routes.d.ts +1 -1
  159. package/dist/file-upload/builder.d.ts.map +1 -1
  160. package/dist/file-upload/builder.js +26 -8
  161. package/dist/file-upload/builder.js.map +1 -1
  162. package/dist/file-upload/types.d.ts +5 -1
  163. package/dist/file-upload/types.d.ts.map +1 -1
  164. package/dist/file-upload/types.js.map +1 -1
  165. package/dist/integrations/a2a-continuation-processor.js +43 -18
  166. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  167. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  168. package/dist/mcp/builtin-tools.js +3 -0
  169. package/dist/mcp/builtin-tools.js.map +1 -1
  170. package/dist/mcp/org-directory.d.ts.map +1 -1
  171. package/dist/mcp/org-directory.js +58 -20
  172. package/dist/mcp/org-directory.js.map +1 -1
  173. package/dist/notifications/channels.d.ts +10 -0
  174. package/dist/notifications/channels.d.ts.map +1 -1
  175. package/dist/notifications/channels.js +172 -20
  176. package/dist/notifications/channels.js.map +1 -1
  177. package/dist/notifications/routes.d.ts +3 -3
  178. package/dist/observability/routes.d.ts +5 -5
  179. package/dist/progress/routes.d.ts +1 -1
  180. package/dist/resources/handlers.d.ts +1 -1
  181. package/dist/scripts/call-agent.d.ts.map +1 -1
  182. package/dist/scripts/call-agent.js +2 -2
  183. package/dist/scripts/call-agent.js.map +1 -1
  184. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  185. package/dist/server/analytics.d.ts +16 -0
  186. package/dist/server/analytics.d.ts.map +1 -1
  187. package/dist/server/analytics.js +30 -7
  188. package/dist/server/analytics.js.map +1 -1
  189. package/dist/server/ssr-handler.d.ts.map +1 -1
  190. package/dist/server/ssr-handler.js +28 -12
  191. package/dist/server/ssr-handler.js.map +1 -1
  192. package/dist/server/transcribe-voice.d.ts +1 -1
  193. 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 SessionReplayEventsResponse = {
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", { message: error.message })}
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: SessionReplayEventsResponse;
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: SessionReplayEventsResponse;
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: SessionReplayEventsResponse,
1109
+ response: SessionReplayPlaybackResponse,
961
1110
  ): AnyReplayEvent[] {
962
1111
  return useMemo(
963
1112
  () =>
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Analytics can now notify inbox, email, Slack, or webhook channels when first-party events spike.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Session replays load large production recordings from scoped chunks instead of truncating playback.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Session replays now play back in production instead of showing a blank recording, because chunk downloads no longer rely on a manual gzip content-encoding that serverless hosts corrupted.
@@ -20,3 +20,6 @@ timeout = 75
20
20
  [functions.dashboard-report-sweep-background]
21
21
  timeout = 300
22
22
  memory = "2gb"
23
+
24
+ [functions.analytics-alert-sweep-background]
25
+ timeout = 120
@@ -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.data;
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
+ }