@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +16 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/model-config.ts +1 -1
- package/corpus/core/src/cli/skills.ts +17 -3
- package/corpus/core/src/client/AssistantChat.tsx +27 -4
- package/corpus/core/src/client/guided-questions.tsx +11 -6
- package/corpus/core/src/client/tool-display.ts +8 -0
- package/corpus/core/src/file-upload/builder.ts +7 -2
- package/corpus/core/src/notifications/channels.ts +235 -38
- package/corpus/core/src/notifications/index.ts +1 -0
- package/corpus/core/src/notifications/registry.ts +13 -1
- package/corpus/core/src/server/analytics.ts +32 -6
- package/corpus/core/src/server/ssr-handler.ts +31 -12
- package/corpus/core/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/analytics/.agents/skills/frontend-design/SKILL.md +2 -0
- 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/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-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 +126 -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 +74 -0
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +803 -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/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/assets/README.md +30 -8
- package/corpus/templates/brain/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/brain/README.md +20 -552
- package/corpus/templates/calendar/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/calendar/README.md +22 -28
- package/corpus/templates/chat/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/chat/README.md +32 -0
- package/corpus/templates/clips/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +30 -1
- package/corpus/templates/clips/README.md +37 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +84 -4
- package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +256 -0
- package/corpus/templates/clips/actions/reprocess-recording.ts +160 -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/recorder/recorder-engine.ts +41 -1
- 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-shared-clips-now-start-playing-and-scrub-instantly-instead-o.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/server/lib/video-remux.ts +222 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +37 -0
- package/corpus/templates/content/.agents/skills/frontend-design/SKILL.md +2 -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/skills/design-generation/SKILL.md +75 -19
- package/corpus/templates/design/.agents/skills/frontend-design/SKILL.md +2 -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 +13 -3
- package/corpus/templates/design/changelog/2026-07-01-design-chat-no-longer-shows-an-empty-state-selection-chip-in-the-composer.md +6 -0
- package/corpus/templates/dispatch/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/dispatch/README.md +34 -0
- package/corpus/templates/forms/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/forms/README.md +34 -0
- package/corpus/templates/macros/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/macros/README.md +28 -0
- package/corpus/templates/mail/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/mail/README.md +35 -0
- package/corpus/templates/plan/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/plan/README.md +30 -118
- package/corpus/templates/slides/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/slides/README.md +34 -0
- package/corpus/templates/videos/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/videos/README.md +22 -201
- package/dist/agent/model-config.js +1 -1
- package/dist/agent/model-config.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +17 -3
- package/dist/cli/skills.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/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/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/routes.d.ts +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +6 -2
- package/dist/file-upload/builder.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/index.d.ts +1 -1
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +1 -1
- package/dist/notifications/index.js.map +1 -1
- package/dist/notifications/registry.d.ts +5 -0
- package/dist/notifications/registry.d.ts.map +1 -1
- package/dist/notifications/registry.js +4 -1
- package/dist/notifications/registry.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.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/dist/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/dist/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- 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
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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'
|
|
310
|
-
// script
|
|
311
|
-
//
|
|
312
|
-
//
|
|
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
|
|
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
|
+
});
|
|
@@ -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.
|
|
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
|
+
}
|