@agent-native/core 0.84.21 → 0.84.23

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 (137) 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/agent/model-config.ts +1 -1
  5. package/corpus/core/src/cli/skills.ts +17 -3
  6. package/corpus/core/src/client/AssistantChat.tsx +27 -4
  7. package/corpus/core/src/client/guided-questions.tsx +11 -6
  8. package/corpus/core/src/client/tool-display.ts +8 -0
  9. package/corpus/core/src/file-upload/builder.ts +7 -2
  10. package/corpus/core/src/notifications/channels.ts +235 -38
  11. package/corpus/core/src/notifications/index.ts +1 -0
  12. package/corpus/core/src/notifications/registry.ts +13 -1
  13. package/corpus/core/src/server/analytics.ts +32 -6
  14. package/corpus/core/src/server/ssr-handler.ts +31 -12
  15. package/corpus/core/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
  16. package/corpus/core/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
  17. package/corpus/templates/analytics/.agents/skills/frontend-design/SKILL.md +2 -0
  18. package/corpus/templates/analytics/AGENTS.md +21 -0
  19. package/corpus/templates/analytics/README.md +35 -0
  20. package/corpus/templates/analytics/actions/delete-analytics-alert-rule.ts +23 -0
  21. package/corpus/templates/analytics/actions/list-analytics-alert-rules.ts +20 -0
  22. package/corpus/templates/analytics/actions/run-analytics-alerts.ts +23 -0
  23. package/corpus/templates/analytics/actions/save-analytics-alert-rule.ts +69 -0
  24. package/corpus/templates/analytics/changelog/2026-07-01-analytics-can-alert-when-first-party-events-spike.md +6 -0
  25. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-play-back-in-production-instead-of-showing.md +6 -0
  26. package/corpus/templates/analytics/netlify.toml +3 -0
  27. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +126 -0
  28. package/corpus/templates/analytics/server/db/schema.ts +55 -0
  29. package/corpus/templates/analytics/server/handlers/session-replay.ts +5 -2
  30. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +74 -0
  31. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +803 -0
  32. package/corpus/templates/analytics/server/lib/session-replay.ts +10 -3
  33. package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +54 -0
  34. package/corpus/templates/analytics/server/plugins/db.ts +100 -0
  35. package/corpus/templates/analytics/server/routes/api/analytics-alerts/run.post.ts +60 -0
  36. package/corpus/templates/assets/.agents/skills/frontend-design/SKILL.md +2 -0
  37. package/corpus/templates/assets/README.md +30 -8
  38. package/corpus/templates/brain/.agents/skills/frontend-design/SKILL.md +2 -0
  39. package/corpus/templates/brain/README.md +20 -552
  40. package/corpus/templates/calendar/.agents/skills/frontend-design/SKILL.md +2 -0
  41. package/corpus/templates/calendar/README.md +22 -28
  42. package/corpus/templates/chat/.agents/skills/frontend-design/SKILL.md +2 -0
  43. package/corpus/templates/chat/README.md +32 -0
  44. package/corpus/templates/clips/.agents/skills/frontend-design/SKILL.md +2 -0
  45. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +30 -1
  46. package/corpus/templates/clips/README.md +37 -0
  47. package/corpus/templates/clips/actions/finalize-recording.ts +84 -4
  48. package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +256 -0
  49. package/corpus/templates/clips/actions/reprocess-recording.ts +160 -0
  50. package/corpus/templates/clips/app/components/library/library-grid.tsx +18 -7
  51. package/corpus/templates/clips/app/components/library/recording-card.tsx +42 -5
  52. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +41 -1
  53. package/corpus/templates/clips/changelog/2026-07-01-move-to-folder-now-opens-folder-choices-directly-instead-of.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-01-shared-clips-now-start-playing-and-scrub-instantly-instead-o.md +6 -0
  55. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  56. package/corpus/templates/clips/server/lib/video-remux.ts +222 -0
  57. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +37 -0
  58. package/corpus/templates/content/.agents/skills/frontend-design/SKILL.md +2 -0
  59. package/corpus/templates/content/AGENTS.md +7 -3
  60. package/corpus/templates/content/README.md +22 -34
  61. package/corpus/templates/content/actions/list-trashed-content-databases.ts +4 -0
  62. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +58 -6
  63. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +257 -24
  64. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +41 -15
  65. package/corpus/templates/content/app/i18n/zh-TW.ts +25 -7
  66. package/corpus/templates/content/app/i18n-data.ts +278 -40
  67. package/corpus/templates/content/changelog/2026-06-30-database-once-again-creates-an-inline-database-in-the-curren.md +6 -0
  68. package/corpus/templates/content/shared/api.ts +1 -0
  69. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +75 -19
  70. package/corpus/templates/design/.agents/skills/frontend-design/SKILL.md +2 -0
  71. package/corpus/templates/design/AGENTS.md +7 -6
  72. package/corpus/templates/design/README.md +33 -0
  73. package/corpus/templates/design/actions/delete-file.ts +86 -10
  74. package/corpus/templates/design/actions/get-design-snapshot.ts +13 -0
  75. package/corpus/templates/design/actions/present-design-variants.ts +11 -13
  76. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  77. package/corpus/templates/design/app/pages/DesignEditor.tsx +13 -3
  78. package/corpus/templates/design/changelog/2026-07-01-design-chat-no-longer-shows-an-empty-state-selection-chip-in-the-composer.md +6 -0
  79. package/corpus/templates/dispatch/.agents/skills/frontend-design/SKILL.md +2 -0
  80. package/corpus/templates/dispatch/README.md +34 -0
  81. package/corpus/templates/forms/.agents/skills/frontend-design/SKILL.md +2 -0
  82. package/corpus/templates/forms/README.md +34 -0
  83. package/corpus/templates/macros/.agents/skills/frontend-design/SKILL.md +2 -0
  84. package/corpus/templates/macros/README.md +28 -0
  85. package/corpus/templates/mail/.agents/skills/frontend-design/SKILL.md +2 -0
  86. package/corpus/templates/mail/README.md +35 -0
  87. package/corpus/templates/plan/.agents/skills/frontend-design/SKILL.md +2 -0
  88. package/corpus/templates/plan/README.md +30 -118
  89. package/corpus/templates/slides/.agents/skills/frontend-design/SKILL.md +2 -0
  90. package/corpus/templates/slides/README.md +34 -0
  91. package/corpus/templates/videos/.agents/skills/frontend-design/SKILL.md +2 -0
  92. package/corpus/templates/videos/README.md +22 -201
  93. package/dist/agent/model-config.js +1 -1
  94. package/dist/agent/model-config.js.map +1 -1
  95. package/dist/cli/skills.d.ts.map +1 -1
  96. package/dist/cli/skills.js +17 -3
  97. package/dist/cli/skills.js.map +1 -1
  98. package/dist/client/AssistantChat.d.ts.map +1 -1
  99. package/dist/client/AssistantChat.js +28 -4
  100. package/dist/client/AssistantChat.js.map +1 -1
  101. package/dist/client/guided-questions.d.ts.map +1 -1
  102. package/dist/client/guided-questions.js +10 -6
  103. package/dist/client/guided-questions.js.map +1 -1
  104. package/dist/client/tool-display.d.ts.map +1 -1
  105. package/dist/client/tool-display.js +8 -0
  106. package/dist/client/tool-display.js.map +1 -1
  107. package/dist/collab/routes.d.ts +1 -1
  108. package/dist/file-upload/builder.d.ts.map +1 -1
  109. package/dist/file-upload/builder.js +6 -2
  110. package/dist/file-upload/builder.js.map +1 -1
  111. package/dist/notifications/channels.d.ts +10 -0
  112. package/dist/notifications/channels.d.ts.map +1 -1
  113. package/dist/notifications/channels.js +172 -20
  114. package/dist/notifications/channels.js.map +1 -1
  115. package/dist/notifications/index.d.ts +1 -1
  116. package/dist/notifications/index.d.ts.map +1 -1
  117. package/dist/notifications/index.js +1 -1
  118. package/dist/notifications/index.js.map +1 -1
  119. package/dist/notifications/registry.d.ts +5 -0
  120. package/dist/notifications/registry.d.ts.map +1 -1
  121. package/dist/notifications/registry.js +4 -1
  122. package/dist/notifications/registry.js.map +1 -1
  123. package/dist/progress/routes.d.ts +1 -1
  124. package/dist/resources/handlers.d.ts +1 -1
  125. package/dist/server/analytics.d.ts +16 -0
  126. package/dist/server/analytics.d.ts.map +1 -1
  127. package/dist/server/analytics.js +30 -7
  128. package/dist/server/analytics.js.map +1 -1
  129. package/dist/server/ssr-handler.d.ts.map +1 -1
  130. package/dist/server/ssr-handler.js +28 -12
  131. package/dist/server/ssr-handler.js.map +1 -1
  132. package/dist/server/transcribe-voice.d.ts +1 -1
  133. package/dist/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
  134. package/dist/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
  135. package/package.json +1 -1
  136. package/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
  137. package/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
@@ -30,6 +30,10 @@ import {
30
30
  AGENT_NATIVE_SOCIAL_IMAGE_WIDTH,
31
31
  withAgentNativeSocialImageCacheBuster,
32
32
  } from "../shared/social-meta.js";
33
+ import {
34
+ GA_CSP_SCRIPT_HOSTS,
35
+ getGaInlineConfigScriptBody,
36
+ } from "./analytics.js";
33
37
  import {
34
38
  getAppBasePathFromViteEnv,
35
39
  stripAppBasePath as canonicalStripAppBasePath,
@@ -278,13 +282,18 @@ function extractScriptBody(scriptTag: string | null): string | null {
278
282
  * user-controlled content reaches the HTML).
279
283
  *
280
284
  * A third directive, `script-src`, is emitted via `Content-Security-Policy-
281
- * Report-Only` rather than enforced. The framework injects one deterministic
282
- * inline script per process (the Sentry config block its hash is computed
283
- * once at process startup from the resolved env vars). Templates additionally
284
- * render a theme-init inline script whose exact content varies by template
285
- * (default theme param, custom docs variant, etc.) and which is rendered by
286
- * React Router, not this handler, so its hash is not available here. Shipping
287
- * script-src as Report-Only surfaces violations without breaking template
285
+ * Report-Only` rather than enforced. The framework injects deterministic inline
286
+ * scripts (the Sentry config block, whose hash is computed once at process
287
+ * startup from the resolved env vars, and — when `GA_MEASUREMENT_ID` is set —
288
+ * the gtag config block, whose hash is derived from the same string
289
+ * `wrapWithAnalytics` embeds). It also loads Google Tag Manager / GA4 from
290
+ * `GA_CSP_SCRIPT_HOSTS`. All of those are listed here so the report-only policy
291
+ * reflects the code the framework itself injects instead of reporting a
292
+ * violation on every page load. Templates additionally render a theme-init
293
+ * inline script whose exact content varies by template (default theme param,
294
+ * custom docs variant, etc.) and which is rendered by React Router, not this
295
+ * handler, so its hash is not available here. Shipping script-src as
296
+ * Report-Only surfaces the remaining violations without breaking template
288
297
  * customisations; teams can graduate to enforcement once their hashes are
289
298
  * enumerated.
290
299
  *
@@ -306,13 +315,23 @@ function applyDocumentCsp(headers: Headers, sentryScript: string | null): void {
306
315
  );
307
316
  }
308
317
 
309
- // script-src as Report-Only: list 'self' plus the hash for the Sentry config
310
- // script the SSR handler injects into every HTML response (the hash is
311
- // computed once from the resolved env vars at process startup). Template
312
- // theme-init hashes are NOT included here see function comment above.
318
+ // script-src as Report-Only: list 'self', the framework-injected inline
319
+ // script hashes (Sentry config + gtag config), and the Google Analytics /
320
+ // Tag Manager loader hosts. These are exactly the scripts the framework
321
+ // itself injects, so listing them keeps the report-only policy from flagging
322
+ // GA on every page load (and keeps it safe to graduate to enforcement).
323
+ // Template theme-init hashes are NOT included here — see function comment.
313
324
  const sentryBody = extractScriptBody(sentryScript);
314
325
  const sentryHash = sentryBody ? computeInlineScriptHash(sentryBody) : null;
315
- const scriptSrcTokens = ["'self'", ...(sentryHash ? [sentryHash] : [])];
326
+ const gaInlineBody = getGaInlineConfigScriptBody();
327
+ const gaHash = gaInlineBody ? computeInlineScriptHash(gaInlineBody) : null;
328
+ const gaHosts = gaInlineBody ? [...GA_CSP_SCRIPT_HOSTS] : [];
329
+ const scriptSrcTokens = [
330
+ "'self'",
331
+ ...(sentryHash ? [sentryHash] : []),
332
+ ...(gaHash ? [gaHash] : []),
333
+ ...gaHosts,
334
+ ];
316
335
  const scriptSrc = `script-src ${scriptSrcTokens.join(" ")}`;
317
336
 
318
337
  const existingRo = headers.get("content-security-policy-report-only") ?? "";
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -107,6 +107,27 @@ details live in `.agents/skills/`.
107
107
  `@sparticuz/chromium-min` in serverless runtimes; set
108
108
  `DASHBOARD_REPORT_CHROMIUM_PACK_URL` only when overriding the default
109
109
  Chromium pack location.
110
+ - Analytics alert rules live in SQL via the `analytics-alert-rules` actions.
111
+ Use `save-analytics-alert-rule`, `list-analytics-alert-rules`,
112
+ `delete-analytics-alert-rule`, and `run-analytics-alerts`; do not hardcode
113
+ one-off alert checks in product code. Rules are generic over first-party
114
+ analytics events: set `eventName` for an exact event name, then filter on
115
+ columns like `event_name`, `app`, `template`, `user_key`, `session_id`,
116
+ `path`, or nested JSON fields like `properties.stage` and
117
+ `context.referrer`. `thresholdMode: "event_count"` counts matching events;
118
+ `thresholdMode: "distinct_count"` counts unique values from `distinctBy`.
119
+ Alert notifications use the shared notification channel registry, so
120
+ `channels` can include `inbox`, `email`, `slack`, `webhook`, or any custom
121
+ registered channel. Configure Slack with `NOTIFICATIONS_SLACK_WEBHOOK_URL`
122
+ and optional `NOTIFICATIONS_SLACK_WEBHOOK_AUTH`; configure email with
123
+ `NOTIFICATIONS_EMAIL_CHANNEL=1`, existing `RESEND_API_KEY` or
124
+ `SENDGRID_API_KEY` plus `EMAIL_FROM`, and pass per-rule `emailRecipients` or
125
+ the fallback `NOTIFICATIONS_EMAIL_RECIPIENTS`.
126
+ Netlify builds emit an alert cron trigger plus background worker from
127
+ `scripts/emit-netlify-dashboard-report-cron.ts` every five minutes; long-lived
128
+ runtimes use the in-process scheduler unless `ANALYTICS_ALERT_JOBS=0` is set.
129
+ External cron callers can POST `/api/analytics-alerts/run` with
130
+ `Authorization: Bearer $ANALYTICS_ALERTS_CRON_SECRET`.
110
131
 
111
132
  ## Application State
112
133
 
@@ -0,0 +1,35 @@
1
+ # Analytics
2
+
3
+ An open-source, agent-native alternative to Amplitude, Mixpanel, and Looker. Ask
4
+ analytics questions in plain English and get charts, dashboards, and re-runnable
5
+ investigations back — you own the code, the queries, and the data.
6
+
7
+ **Live app: [analytics.agent-native.com](https://analytics.agent-native.com)**
8
+
9
+ The agent connects to your data, writes and validates the SQL for you, and
10
+ renders the answer as a chart, table, metric, or saved dashboard panel. Anything
11
+ you can do in the UI, the agent can do through the same actions.
12
+
13
+ ## Features
14
+
15
+ - Ask data questions in plain English and get charts, tables, or metrics back.
16
+ - Connect BigQuery, GA4, Mixpanel, Amplitude, PostHog, HubSpot, Jira, and more,
17
+ plus a built-in first-party event collector.
18
+ - Build reusable SQL dashboards with filters, saved views, and parametric queries.
19
+ - Save ad-hoc analyses as re-runnable investigations with question, instructions,
20
+ and findings.
21
+ - Maintain a living data dictionary so the agent uses real warehouse column names.
22
+ - Share dashboards per-user or per-org with viewer / editor / admin roles.
23
+
24
+ ## Develop locally
25
+
26
+ Scaffold your own copy and run it:
27
+
28
+ ```bash
29
+ npx @agent-native/core@latest create my-analytics --standalone --template analytics
30
+ cd my-analytics
31
+ pnpm install
32
+ pnpm dev
33
+ ```
34
+
35
+ Full docs: [agent-native.com/docs/template-analytics](https://agent-native.com/docs/template-analytics).
@@ -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 { deleteAnalyticsAlertRule } from "../server/lib/analytics-alerts";
9
+
10
+ export default defineAction({
11
+ description: "Delete a reusable first-party analytics alert rule.",
12
+ schema: z.object({
13
+ id: z.string().describe("Alert rule ID"),
14
+ }),
15
+ http: { method: "POST" },
16
+ run: async ({ id }) => {
17
+ const email = getRequestUserEmail();
18
+ if (!email) throw new Error("no authenticated user");
19
+ const orgId = getRequestOrgId() || null;
20
+ await deleteAnalyticsAlertRule(id, { email, orgId });
21
+ return { ok: true };
22
+ },
23
+ });
@@ -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
+ });
@@ -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 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,122 @@ 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
+ async function readScheduledInvocation(request) {
139
+ if (request.method !== "POST") return null;
140
+ let body;
141
+ try {
142
+ body = await request.json();
143
+ } catch {
144
+ return null;
145
+ }
146
+ const nextRun = typeof body?.next_run === "string" ? body.next_run : "";
147
+ return nextRun && Number.isFinite(Date.parse(nextRun))
148
+ ? { nextRun }
149
+ : null;
150
+ }
151
+
152
+ export default async function handler(request) {
153
+ const scheduled = await readScheduledInvocation(request);
154
+ if (!scheduled) return new Response("Not Found", { status: 404 });
155
+
156
+ const url = new URL(WORKER_PATH, siteOrigin(request));
157
+ const response = await fetch(url.toString(), {
158
+ method: "POST",
159
+ headers: {
160
+ "content-type": "application/json",
161
+ "x-agent-native-analytics-alert-cron": CRON_TOKEN,
162
+ },
163
+ body: JSON.stringify({ scheduled: true, next_run: scheduled.nextRun }),
164
+ });
165
+
166
+ if (!response.ok && response.status !== 202) {
167
+ console.error(
168
+ "[analytics-alert-cron] Background sweep trigger failed:",
169
+ response.status,
170
+ await response.text().catch(() => ""),
171
+ );
172
+ }
173
+ }
174
+
175
+ export const config = {
176
+ name: "analytics alert cron trigger",
177
+ generator: "agent-native analytics build",
178
+ schedule: ${JSON.stringify(ALERT_SCHEDULE)},
179
+ };
180
+ `;
181
+
182
+ writeFileSync(path.join(dest, `${ALERT_SCHEDULED_NAME}.mjs`), source);
183
+ }
184
+
185
+ function emitAlertBackgroundWorker(token: string) {
186
+ const dest = path.join(FUNCTIONS_DIR, ALERT_WORKER_NAME);
187
+ rmSync(dest, { recursive: true, force: true });
188
+ cpSync(SERVER_DIR, dest, { recursive: true });
189
+ rmSync(path.join(dest, "server.mjs"), { force: true });
190
+
191
+ const source = `globalThis.__AGENT_NATIVE_ANALYTICS_ALERT_SCHEDULED_RUNTIME__ = true;
192
+
193
+ const CRON_TOKEN = ${JSON.stringify(token)};
194
+ const ROUTE_PATH = ${JSON.stringify(ALERT_ROUTE_PATH)};
195
+ let cachedHandler;
196
+
197
+ function timingSafeEquals(a, b) {
198
+ if (a.length !== b.length) return false;
199
+ let diff = 0;
200
+ for (let i = 0; i < a.length; i += 1) {
201
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
202
+ }
203
+ return diff === 0;
204
+ }
205
+
206
+ export default async function handler(request, context) {
207
+ const token = request.headers.get("x-agent-native-analytics-alert-cron") || "";
208
+ if (!timingSafeEquals(token, CRON_TOKEN)) {
209
+ return new Response("Unauthorized", { status: 401 });
210
+ }
211
+
212
+ cachedHandler ??= (await import("./main.mjs")).default;
213
+ const url = new URL(request.url);
214
+ url.pathname = ROUTE_PATH;
215
+ url.search = "";
216
+
217
+ const rewritten = new Request(url.toString(), {
218
+ method: "POST",
219
+ headers: new Headers({ "content-type": "application/json" }),
220
+ body: JSON.stringify({ scheduled: true }),
221
+ });
222
+
223
+ return await cachedHandler(rewritten, context);
224
+ }
225
+
226
+ export const config = {
227
+ name: "analytics alert background sweep",
228
+ generator: "agent-native analytics build",
229
+ background: true,
230
+ nodeBundler: "none",
231
+ includedFiles: ["**"],
232
+ preferStatic: false,
233
+ };
234
+ `;
235
+
236
+ writeFileSync(path.join(dest, `${ALERT_WORKER_NAME}.mjs`), source);
237
+ }
238
+
119
239
  function isDirectRun(): boolean {
120
240
  const entrypoint = process.argv[1];
121
241
  return Boolean(
@@ -133,11 +253,17 @@ function main(): void {
133
253
  }
134
254
 
135
255
  const token = randomBytes(32).toString("hex");
256
+ const alertToken = randomBytes(32).toString("hex");
136
257
  emitScheduledTrigger(token);
137
258
  emitBackgroundWorker(token);
259
+ emitAlertScheduledTrigger(alertToken);
260
+ emitAlertBackgroundWorker(alertToken);
138
261
  console.log(
139
262
  `[dashboard-report-cron] Emitted Netlify scheduled trigger "${SCHEDULED_NAME}" (${SCHEDULE}) and background worker "${WORKER_NAME}".`,
140
263
  );
264
+ console.log(
265
+ `[analytics-alert-cron] Emitted Netlify scheduled trigger "${ALERT_SCHEDULED_NAME}" (${ALERT_SCHEDULE}) and background worker "${ALERT_WORKER_NAME}".`,
266
+ );
141
267
  }
142
268
 
143
269
  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", "running"],
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,74 @@
1
+ import {
2
+ claimAnalyticsAlertRuleEvaluation,
3
+ evaluateAndNotifyAnalyticsAlertRule,
4
+ listEnabledAnalyticsAlertRules,
5
+ markAnalyticsAlertRuleError,
6
+ } from "../lib/analytics-alerts";
7
+
8
+ let running = false;
9
+ const DEFAULT_MAX_RULES_PER_SWEEP = 100;
10
+
11
+ function maxRulesPerSweep(input?: number): number {
12
+ if (input) return Math.max(1, Math.min(500, Math.floor(input)));
13
+ const raw = process.env.ANALYTICS_ALERT_SWEEP_LIMIT?.trim();
14
+ if (!raw) return DEFAULT_MAX_RULES_PER_SWEEP;
15
+ const parsed = Number.parseInt(raw, 10);
16
+ return Number.isFinite(parsed) && parsed > 0
17
+ ? parsed
18
+ : DEFAULT_MAX_RULES_PER_SWEEP;
19
+ }
20
+
21
+ /**
22
+ * Run one analytics alert sweep. Exported for deployment-specific scheduled
23
+ * functions that should not rely on a long-lived Node process.
24
+ */
25
+ export async function runAnalyticsAlertsOnce(
26
+ options: {
27
+ ownerEmail?: string;
28
+ orgId?: string | null;
29
+ limit?: number;
30
+ } = {},
31
+ ): Promise<{
32
+ processed: number;
33
+ triggered: number;
34
+ failed: number;
35
+ remaining: number;
36
+ }> {
37
+ if (running) return { processed: 0, triggered: 0, failed: 0, remaining: 0 };
38
+ running = true;
39
+ let processed = 0;
40
+ let triggered = 0;
41
+ let failed = 0;
42
+ let remaining = 0;
43
+
44
+ try {
45
+ const sweepLimit = maxRulesPerSweep(options.limit);
46
+ const rules = await listEnabledAnalyticsAlertRules({
47
+ limit: sweepLimit,
48
+ ownerEmail: options.ownerEmail,
49
+ orgId: options.orgId,
50
+ });
51
+ remaining = rules.length >= sweepLimit ? 1 : 0;
52
+
53
+ for (const rule of rules) {
54
+ try {
55
+ const claimed = await claimAnalyticsAlertRuleEvaluation(rule);
56
+ if (!claimed) continue;
57
+ processed++;
58
+ const result = await evaluateAndNotifyAnalyticsAlertRule(rule);
59
+ if (result.status === "triggered") triggered++;
60
+ } catch (err) {
61
+ failed++;
62
+ await markAnalyticsAlertRuleError(rule.id, err).catch(() => {});
63
+ console.error(
64
+ `[analytics-alerts] Rule ${rule.id} (${rule.name}) failed:`,
65
+ err,
66
+ );
67
+ }
68
+ }
69
+ } finally {
70
+ running = false;
71
+ }
72
+
73
+ return { processed, triggered, failed, remaining };
74
+ }