@agent-native/core 0.84.21 → 0.84.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +8 -0
- package/corpus/core/package.json +1 -1
- 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/server/analytics.ts +32 -6
- package/corpus/core/src/server/ssr-handler.ts +31 -12
- package/corpus/templates/analytics/AGENTS.md +21 -0
- package/corpus/templates/analytics/README.md +35 -0
- package/corpus/templates/analytics/actions/delete-analytics-alert-rule.ts +23 -0
- package/corpus/templates/analytics/actions/list-analytics-alert-rules.ts +20 -0
- package/corpus/templates/analytics/actions/run-analytics-alerts.ts +23 -0
- package/corpus/templates/analytics/actions/save-analytics-alert-rule.ts +69 -0
- package/corpus/templates/analytics/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 +109 -0
- package/corpus/templates/analytics/server/db/schema.ts +55 -0
- package/corpus/templates/analytics/server/handlers/session-replay.ts +5 -2
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +71 -0
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +734 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +10 -3
- package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +54 -0
- package/corpus/templates/analytics/server/plugins/db.ts +100 -0
- package/corpus/templates/analytics/server/routes/api/analytics-alerts/run.post.ts +60 -0
- package/corpus/templates/assets/README.md +30 -8
- package/corpus/templates/brain/README.md +20 -552
- package/corpus/templates/calendar/README.md +22 -28
- package/corpus/templates/chat/README.md +32 -0
- package/corpus/templates/clips/README.md +37 -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/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +37 -0
- package/corpus/templates/content/AGENTS.md +7 -3
- package/corpus/templates/content/README.md +22 -34
- package/corpus/templates/content/actions/list-trashed-content-databases.ts +4 -0
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +58 -6
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +257 -24
- package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +41 -15
- package/corpus/templates/content/app/i18n/zh-TW.ts +25 -7
- package/corpus/templates/content/app/i18n-data.ts +278 -40
- package/corpus/templates/content/changelog/2026-06-30-database-once-again-creates-an-inline-database-in-the-curren.md +6 -0
- package/corpus/templates/content/shared/api.ts +1 -0
- package/corpus/templates/design/AGENTS.md +7 -6
- package/corpus/templates/design/README.md +33 -0
- package/corpus/templates/design/actions/delete-file.ts +86 -10
- package/corpus/templates/design/actions/get-design-snapshot.ts +13 -0
- package/corpus/templates/design/actions/present-design-variants.ts +11 -13
- package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
- package/corpus/templates/design/app/pages/DesignEditor.tsx +2 -2
- package/corpus/templates/dispatch/README.md +34 -0
- package/corpus/templates/forms/README.md +34 -0
- package/corpus/templates/macros/README.md +28 -0
- package/corpus/templates/mail/README.md +35 -0
- package/corpus/templates/plan/README.md +30 -118
- package/corpus/templates/slides/README.md +34 -0
- package/corpus/templates/videos/README.md +22 -201
- 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/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +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/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/analytics.d.ts +16 -0
- package/dist/server/analytics.d.ts.map +1 -1
- package/dist/server/analytics.js +30 -7
- package/dist/server/analytics.js.map +1 -1
- package/dist/server/ssr-handler.d.ts.map +1 -1
- package/dist/server/ssr-handler.js +28 -12
- package/dist/server/ssr-handler.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { notify } from "@agent-native/core/notifications";
|
|
4
|
+
import { recordChange } from "@agent-native/core/server";
|
|
5
|
+
import { and, asc, desc, eq, gte, isNull, lte, sql } from "drizzle-orm";
|
|
6
|
+
|
|
7
|
+
import { getDb, schema } from "../db/index.js";
|
|
8
|
+
|
|
9
|
+
export type AnalyticsAlertFilterOp =
|
|
10
|
+
| "equals"
|
|
11
|
+
| "not_equals"
|
|
12
|
+
| "contains"
|
|
13
|
+
| "in"
|
|
14
|
+
| "exists";
|
|
15
|
+
|
|
16
|
+
export interface AnalyticsAlertFilter {
|
|
17
|
+
field: string;
|
|
18
|
+
op?: AnalyticsAlertFilterOp;
|
|
19
|
+
value?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type AnalyticsAlertThresholdMode = "event_count" | "distinct_count";
|
|
23
|
+
export type AnalyticsAlertSeverity = "warning" | "critical";
|
|
24
|
+
export type AnalyticsAlertStatus = "ok" | "triggered" | "cooldown" | "error";
|
|
25
|
+
|
|
26
|
+
export interface AnalyticsAlertRuleInput {
|
|
27
|
+
id?: string;
|
|
28
|
+
name: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
eventName?: string | null;
|
|
31
|
+
filters?: AnalyticsAlertFilter[];
|
|
32
|
+
thresholdMode?: AnalyticsAlertThresholdMode;
|
|
33
|
+
distinctBy?: string | null;
|
|
34
|
+
threshold: number;
|
|
35
|
+
windowMinutes: number;
|
|
36
|
+
cooldownMinutes?: number;
|
|
37
|
+
severity?: AnalyticsAlertSeverity;
|
|
38
|
+
channels?: string[];
|
|
39
|
+
emailRecipients?: string[];
|
|
40
|
+
enabled?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AnalyticsAlertRule {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
description: string;
|
|
47
|
+
eventName: string | null;
|
|
48
|
+
filters: AnalyticsAlertFilter[];
|
|
49
|
+
thresholdMode: AnalyticsAlertThresholdMode;
|
|
50
|
+
distinctBy: string | null;
|
|
51
|
+
threshold: number;
|
|
52
|
+
windowMinutes: number;
|
|
53
|
+
cooldownMinutes: number;
|
|
54
|
+
severity: AnalyticsAlertSeverity;
|
|
55
|
+
channels: string[];
|
|
56
|
+
emailRecipients: string[];
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
lastEvaluatedAt: string | null;
|
|
59
|
+
lastTriggeredAt: string | null;
|
|
60
|
+
lastStatus: AnalyticsAlertStatus | null;
|
|
61
|
+
lastError: string | null;
|
|
62
|
+
createdAt: string;
|
|
63
|
+
updatedAt: string;
|
|
64
|
+
ownerEmail: string;
|
|
65
|
+
orgId: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AnalyticsAlertEventRow {
|
|
69
|
+
id: string;
|
|
70
|
+
eventName: string;
|
|
71
|
+
userId?: string | null;
|
|
72
|
+
anonymousId?: string | null;
|
|
73
|
+
userKey?: string | null;
|
|
74
|
+
sessionId?: string | null;
|
|
75
|
+
timestamp: string;
|
|
76
|
+
eventDate?: string | null;
|
|
77
|
+
receivedAt?: string | null;
|
|
78
|
+
url?: string | null;
|
|
79
|
+
path?: string | null;
|
|
80
|
+
hostname?: string | null;
|
|
81
|
+
referrer?: string | null;
|
|
82
|
+
app?: string | null;
|
|
83
|
+
template?: string | null;
|
|
84
|
+
signedIn?: string | null;
|
|
85
|
+
properties?: string | null;
|
|
86
|
+
context?: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AnalyticsAlertEvaluation {
|
|
90
|
+
triggered: boolean;
|
|
91
|
+
observedValue: number;
|
|
92
|
+
eventCount: number;
|
|
93
|
+
sampleEvents: Array<Record<string, unknown>>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface AccessCtx {
|
|
97
|
+
email: string;
|
|
98
|
+
orgId: string | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface RunRuleResult extends AnalyticsAlertEvaluation {
|
|
102
|
+
ruleId: string;
|
|
103
|
+
status: AnalyticsAlertStatus;
|
|
104
|
+
notificationId?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function nowIso(): string {
|
|
108
|
+
return new Date().toISOString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function safeJsonParse<T>(raw: unknown, fallback: T): T {
|
|
112
|
+
if (typeof raw !== "string" || !raw.trim()) return fallback;
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(raw) as T;
|
|
115
|
+
return parsed ?? fallback;
|
|
116
|
+
} catch {
|
|
117
|
+
return fallback;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeName(name: string): string {
|
|
122
|
+
const normalized = name.trim();
|
|
123
|
+
if (!normalized) throw new Error("Alert name is required");
|
|
124
|
+
return normalized.slice(0, 120);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeNullableText(
|
|
128
|
+
value: string | null | undefined,
|
|
129
|
+
): string | null {
|
|
130
|
+
const normalized = value?.trim();
|
|
131
|
+
return normalized ? normalized : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function clampInt(value: number, min: number, max: number): number {
|
|
135
|
+
const normalized = Math.floor(Number(value));
|
|
136
|
+
if (!Number.isFinite(normalized)) return min;
|
|
137
|
+
return Math.max(min, Math.min(max, normalized));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizeFilters(filters: AnalyticsAlertFilter[] | undefined) {
|
|
141
|
+
const normalized: AnalyticsAlertFilter[] = [];
|
|
142
|
+
for (const filter of filters ?? []) {
|
|
143
|
+
const field = filter.field?.trim();
|
|
144
|
+
if (!field) continue;
|
|
145
|
+
const op = filter.op ?? "equals";
|
|
146
|
+
if (!["equals", "not_equals", "contains", "in", "exists"].includes(op)) {
|
|
147
|
+
throw new Error(`Unsupported alert filter operator: ${op}`);
|
|
148
|
+
}
|
|
149
|
+
normalized.push({ field, op, value: filter.value });
|
|
150
|
+
}
|
|
151
|
+
return normalized;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeChannels(
|
|
155
|
+
channels: string[] | undefined,
|
|
156
|
+
emailRecipients: string[],
|
|
157
|
+
): string[] {
|
|
158
|
+
const source =
|
|
159
|
+
channels && channels.length
|
|
160
|
+
? channels
|
|
161
|
+
: emailRecipients.length
|
|
162
|
+
? ["inbox", "email"]
|
|
163
|
+
: ["inbox"];
|
|
164
|
+
const seen = new Set<string>();
|
|
165
|
+
const normalized: string[] = [];
|
|
166
|
+
for (const raw of source) {
|
|
167
|
+
const channel = raw.trim();
|
|
168
|
+
if (!channel || seen.has(channel)) continue;
|
|
169
|
+
seen.add(channel);
|
|
170
|
+
normalized.push(channel);
|
|
171
|
+
}
|
|
172
|
+
return normalized.length ? normalized : ["inbox"];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeEmailRecipients(recipients: string[] | undefined): string[] {
|
|
176
|
+
const seen = new Set<string>();
|
|
177
|
+
const normalized: string[] = [];
|
|
178
|
+
for (const raw of recipients ?? []) {
|
|
179
|
+
const email = raw.trim().toLowerCase();
|
|
180
|
+
if (!email || seen.has(email)) continue;
|
|
181
|
+
seen.add(email);
|
|
182
|
+
normalized.push(email);
|
|
183
|
+
}
|
|
184
|
+
return normalized;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function rowToRule(row: any): AnalyticsAlertRule {
|
|
188
|
+
return {
|
|
189
|
+
id: row.id,
|
|
190
|
+
name: row.name,
|
|
191
|
+
description: row.description ?? "",
|
|
192
|
+
eventName: row.eventName ?? null,
|
|
193
|
+
filters: safeJsonParse<AnalyticsAlertFilter[]>(row.filters, []),
|
|
194
|
+
thresholdMode:
|
|
195
|
+
row.thresholdMode === "distinct_count" ? "distinct_count" : "event_count",
|
|
196
|
+
distinctBy: row.distinctBy ?? null,
|
|
197
|
+
threshold: Number(row.threshold ?? 1),
|
|
198
|
+
windowMinutes: Number(row.windowMinutes ?? 10),
|
|
199
|
+
cooldownMinutes: Number(row.cooldownMinutes ?? 30),
|
|
200
|
+
severity: row.severity === "critical" ? "critical" : "warning",
|
|
201
|
+
channels: safeJsonParse<string[]>(row.channels, ["inbox"]),
|
|
202
|
+
emailRecipients: safeJsonParse<string[]>(row.emailRecipients, []),
|
|
203
|
+
enabled: row.enabled === true || row.enabled === 1,
|
|
204
|
+
lastEvaluatedAt: row.lastEvaluatedAt ?? null,
|
|
205
|
+
lastTriggeredAt: row.lastTriggeredAt ?? null,
|
|
206
|
+
lastStatus: row.lastStatus ?? null,
|
|
207
|
+
lastError: row.lastError ?? null,
|
|
208
|
+
createdAt: row.createdAt,
|
|
209
|
+
updatedAt: row.updatedAt,
|
|
210
|
+
ownerEmail: row.ownerEmail,
|
|
211
|
+
orgId: row.orgId ?? null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function ownerWhere(ctx: AccessCtx, id?: string) {
|
|
216
|
+
const table = schema.analyticsAlertRules;
|
|
217
|
+
const clauses = [
|
|
218
|
+
sql`lower(${table.ownerEmail}) = ${ctx.email.toLowerCase()}`,
|
|
219
|
+
ctx.orgId ? eq(table.orgId, ctx.orgId) : isNull(table.orgId),
|
|
220
|
+
];
|
|
221
|
+
if (id) clauses.push(eq(table.id, id));
|
|
222
|
+
return and(...clauses);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function listAnalyticsAlertRules(
|
|
226
|
+
ctx: AccessCtx,
|
|
227
|
+
): Promise<AnalyticsAlertRule[]> {
|
|
228
|
+
const db = getDb() as any;
|
|
229
|
+
const rows = await db
|
|
230
|
+
.select()
|
|
231
|
+
.from(schema.analyticsAlertRules)
|
|
232
|
+
.where(ownerWhere(ctx))
|
|
233
|
+
.orderBy(asc(schema.analyticsAlertRules.name));
|
|
234
|
+
return rows.map(rowToRule);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function getAnalyticsAlertRule(
|
|
238
|
+
id: string,
|
|
239
|
+
ctx: AccessCtx,
|
|
240
|
+
): Promise<AnalyticsAlertRule | null> {
|
|
241
|
+
const db = getDb() as any;
|
|
242
|
+
const [row] = await db
|
|
243
|
+
.select()
|
|
244
|
+
.from(schema.analyticsAlertRules)
|
|
245
|
+
.where(ownerWhere(ctx, id));
|
|
246
|
+
return row ? rowToRule(row) : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function saveAnalyticsAlertRule(
|
|
250
|
+
input: AnalyticsAlertRuleInput,
|
|
251
|
+
ctx: AccessCtx,
|
|
252
|
+
): Promise<AnalyticsAlertRule> {
|
|
253
|
+
const updatedAt = nowIso();
|
|
254
|
+
const id = input.id || randomUUID();
|
|
255
|
+
const name = normalizeName(input.name);
|
|
256
|
+
const description = input.description?.trim() ?? "";
|
|
257
|
+
const eventName = normalizeNullableText(input.eventName);
|
|
258
|
+
const filters = normalizeFilters(input.filters);
|
|
259
|
+
const thresholdMode =
|
|
260
|
+
input.thresholdMode === "distinct_count" ? "distinct_count" : "event_count";
|
|
261
|
+
const distinctBy =
|
|
262
|
+
thresholdMode === "distinct_count"
|
|
263
|
+
? normalizeNullableText(input.distinctBy) || "user_key"
|
|
264
|
+
: normalizeNullableText(input.distinctBy);
|
|
265
|
+
const threshold = clampInt(input.threshold, 1, 1_000_000);
|
|
266
|
+
const windowMinutes = clampInt(input.windowMinutes, 1, 24 * 60);
|
|
267
|
+
const cooldownMinutes = clampInt(input.cooldownMinutes ?? 30, 0, 24 * 60);
|
|
268
|
+
const severity = input.severity === "critical" ? "critical" : "warning";
|
|
269
|
+
const emailRecipients = normalizeEmailRecipients(input.emailRecipients);
|
|
270
|
+
const channels = normalizeChannels(input.channels, emailRecipients);
|
|
271
|
+
const enabled = input.enabled ?? true;
|
|
272
|
+
const db = getDb() as any;
|
|
273
|
+
|
|
274
|
+
if (input.id) {
|
|
275
|
+
const existing = await getAnalyticsAlertRule(input.id, ctx);
|
|
276
|
+
if (!existing) {
|
|
277
|
+
throw Object.assign(new Error("Analytics alert rule not found"), {
|
|
278
|
+
statusCode: 404,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
await db
|
|
282
|
+
.update(schema.analyticsAlertRules)
|
|
283
|
+
.set({
|
|
284
|
+
name,
|
|
285
|
+
description,
|
|
286
|
+
eventName,
|
|
287
|
+
filters: JSON.stringify(filters),
|
|
288
|
+
thresholdMode,
|
|
289
|
+
distinctBy,
|
|
290
|
+
threshold,
|
|
291
|
+
windowMinutes,
|
|
292
|
+
cooldownMinutes,
|
|
293
|
+
severity,
|
|
294
|
+
channels: JSON.stringify(channels),
|
|
295
|
+
emailRecipients: JSON.stringify(emailRecipients),
|
|
296
|
+
enabled,
|
|
297
|
+
updatedAt,
|
|
298
|
+
lastError: null,
|
|
299
|
+
})
|
|
300
|
+
.where(ownerWhere(ctx, id));
|
|
301
|
+
} else {
|
|
302
|
+
await db.insert(schema.analyticsAlertRules).values({
|
|
303
|
+
id,
|
|
304
|
+
name,
|
|
305
|
+
description,
|
|
306
|
+
eventName,
|
|
307
|
+
filters: JSON.stringify(filters),
|
|
308
|
+
thresholdMode,
|
|
309
|
+
distinctBy,
|
|
310
|
+
threshold,
|
|
311
|
+
windowMinutes,
|
|
312
|
+
cooldownMinutes,
|
|
313
|
+
severity,
|
|
314
|
+
channels: JSON.stringify(channels),
|
|
315
|
+
emailRecipients: JSON.stringify(emailRecipients),
|
|
316
|
+
enabled,
|
|
317
|
+
createdAt: updatedAt,
|
|
318
|
+
updatedAt,
|
|
319
|
+
ownerEmail: ctx.email,
|
|
320
|
+
orgId: ctx.orgId,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const saved = await getAnalyticsAlertRule(id, ctx);
|
|
325
|
+
if (!saved) throw new Error("Failed to save analytics alert rule");
|
|
326
|
+
recordChange({
|
|
327
|
+
source: "analytics-alert-rules",
|
|
328
|
+
type: "change",
|
|
329
|
+
key: saved.id,
|
|
330
|
+
owner: saved.ownerEmail,
|
|
331
|
+
orgId: saved.orgId ?? undefined,
|
|
332
|
+
});
|
|
333
|
+
return saved;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function deleteAnalyticsAlertRule(
|
|
337
|
+
id: string,
|
|
338
|
+
ctx: AccessCtx,
|
|
339
|
+
): Promise<void> {
|
|
340
|
+
const existing = await getAnalyticsAlertRule(id, ctx);
|
|
341
|
+
if (!existing) {
|
|
342
|
+
throw Object.assign(new Error("Analytics alert rule not found"), {
|
|
343
|
+
statusCode: 404,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const db = getDb() as any;
|
|
347
|
+
await db.delete(schema.analyticsAlertRules).where(ownerWhere(ctx, id));
|
|
348
|
+
recordChange({
|
|
349
|
+
source: "analytics-alert-rules",
|
|
350
|
+
type: "delete",
|
|
351
|
+
key: existing.id,
|
|
352
|
+
owner: existing.ownerEmail,
|
|
353
|
+
orgId: existing.orgId ?? undefined,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function listEnabledAnalyticsAlertRules(options: {
|
|
358
|
+
limit: number;
|
|
359
|
+
ownerEmail?: string;
|
|
360
|
+
orgId?: string | null;
|
|
361
|
+
}): Promise<AnalyticsAlertRule[]> {
|
|
362
|
+
const db = getDb() as any;
|
|
363
|
+
const table = schema.analyticsAlertRules;
|
|
364
|
+
const clauses: any[] = [eq(table.enabled, true)];
|
|
365
|
+
if (options.ownerEmail) {
|
|
366
|
+
clauses.push(
|
|
367
|
+
sql`lower(${table.ownerEmail}) = ${options.ownerEmail.toLowerCase()}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (options.orgId !== undefined) {
|
|
371
|
+
clauses.push(
|
|
372
|
+
options.orgId ? eq(table.orgId, options.orgId) : isNull(table.orgId),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const rows = await db
|
|
376
|
+
.select()
|
|
377
|
+
.from(table)
|
|
378
|
+
.where(and(...clauses))
|
|
379
|
+
.orderBy(
|
|
380
|
+
sql`case when ${table.lastEvaluatedAt} is null then 0 else 1 end`,
|
|
381
|
+
asc(table.lastEvaluatedAt),
|
|
382
|
+
asc(table.createdAt),
|
|
383
|
+
)
|
|
384
|
+
.limit(clampInt(options.limit, 1, 500));
|
|
385
|
+
return rows.map(rowToRule);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function evaluateAndNotifyAnalyticsAlertRule(
|
|
389
|
+
rule: AnalyticsAlertRule,
|
|
390
|
+
now: Date = new Date(),
|
|
391
|
+
): Promise<RunRuleResult> {
|
|
392
|
+
const windowEnd = now.toISOString();
|
|
393
|
+
const windowStart = new Date(
|
|
394
|
+
now.getTime() - rule.windowMinutes * 60 * 1000,
|
|
395
|
+
).toISOString();
|
|
396
|
+
const rows = await loadCandidateEvents(rule, windowStart, windowEnd);
|
|
397
|
+
const evaluation = evaluateAnalyticsAlertRuleRows(rule, rows);
|
|
398
|
+
const evaluatedAt = now.toISOString();
|
|
399
|
+
|
|
400
|
+
if (!evaluation.triggered) {
|
|
401
|
+
await markRuleStatus(rule.id, {
|
|
402
|
+
lastEvaluatedAt: evaluatedAt,
|
|
403
|
+
lastStatus: "ok",
|
|
404
|
+
lastError: null,
|
|
405
|
+
});
|
|
406
|
+
return { ruleId: rule.id, status: "ok", ...evaluation };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (isCoolingDown(rule, now)) {
|
|
410
|
+
await markRuleStatus(rule.id, {
|
|
411
|
+
lastEvaluatedAt: evaluatedAt,
|
|
412
|
+
lastStatus: "cooldown",
|
|
413
|
+
lastError: null,
|
|
414
|
+
});
|
|
415
|
+
return { ruleId: rule.id, status: "cooldown", ...evaluation };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const body = alertBody(rule, evaluation);
|
|
419
|
+
const stored = await notify(
|
|
420
|
+
{
|
|
421
|
+
severity: rule.severity,
|
|
422
|
+
title: `Analytics alert: ${rule.name}`,
|
|
423
|
+
body,
|
|
424
|
+
channels: rule.channels,
|
|
425
|
+
metadata: {
|
|
426
|
+
kind: "analytics_alert",
|
|
427
|
+
ruleId: rule.id,
|
|
428
|
+
ruleName: rule.name,
|
|
429
|
+
observedValue: evaluation.observedValue,
|
|
430
|
+
eventCount: evaluation.eventCount,
|
|
431
|
+
threshold: rule.threshold,
|
|
432
|
+
thresholdMode: rule.thresholdMode,
|
|
433
|
+
distinctBy: rule.distinctBy,
|
|
434
|
+
windowMinutes: rule.windowMinutes,
|
|
435
|
+
windowStart,
|
|
436
|
+
windowEnd,
|
|
437
|
+
eventName: rule.eventName,
|
|
438
|
+
filters: rule.filters,
|
|
439
|
+
sampleEvents: evaluation.sampleEvents,
|
|
440
|
+
emailRecipients: rule.emailRecipients,
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
{ owner: rule.ownerEmail },
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
await recordIncident(rule, {
|
|
447
|
+
notificationId: stored?.id,
|
|
448
|
+
triggeredAt: evaluatedAt,
|
|
449
|
+
windowStart,
|
|
450
|
+
windowEnd,
|
|
451
|
+
...evaluation,
|
|
452
|
+
});
|
|
453
|
+
await markRuleStatus(rule.id, {
|
|
454
|
+
lastEvaluatedAt: evaluatedAt,
|
|
455
|
+
lastTriggeredAt: evaluatedAt,
|
|
456
|
+
lastStatus: "triggered",
|
|
457
|
+
lastError: null,
|
|
458
|
+
});
|
|
459
|
+
return {
|
|
460
|
+
ruleId: rule.id,
|
|
461
|
+
status: "triggered",
|
|
462
|
+
notificationId: stored?.id,
|
|
463
|
+
...evaluation,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function markAnalyticsAlertRuleError(
|
|
468
|
+
id: string,
|
|
469
|
+
error: unknown,
|
|
470
|
+
): Promise<void> {
|
|
471
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
472
|
+
await markRuleStatus(id, {
|
|
473
|
+
lastEvaluatedAt: nowIso(),
|
|
474
|
+
lastStatus: "error",
|
|
475
|
+
lastError: message.slice(0, 1000),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function evaluateAnalyticsAlertRuleRows(
|
|
480
|
+
rule: Pick<
|
|
481
|
+
AnalyticsAlertRule,
|
|
482
|
+
"filters" | "threshold" | "thresholdMode" | "distinctBy"
|
|
483
|
+
>,
|
|
484
|
+
rows: AnalyticsAlertEventRow[],
|
|
485
|
+
): AnalyticsAlertEvaluation {
|
|
486
|
+
const matched = rows.filter((row) =>
|
|
487
|
+
rule.filters.every((filter) => matchesFilter(row, filter)),
|
|
488
|
+
);
|
|
489
|
+
const observedValue =
|
|
490
|
+
rule.thresholdMode === "distinct_count"
|
|
491
|
+
? distinctCount(matched, rule.distinctBy || "user_key")
|
|
492
|
+
: matched.length;
|
|
493
|
+
return {
|
|
494
|
+
triggered: observedValue >= rule.threshold,
|
|
495
|
+
observedValue,
|
|
496
|
+
eventCount: matched.length,
|
|
497
|
+
sampleEvents: matched.slice(0, 5).map(sampleEvent),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function loadCandidateEvents(
|
|
502
|
+
rule: AnalyticsAlertRule,
|
|
503
|
+
windowStart: string,
|
|
504
|
+
windowEnd: string,
|
|
505
|
+
): Promise<AnalyticsAlertEventRow[]> {
|
|
506
|
+
const db = getDb() as any;
|
|
507
|
+
const table = schema.analyticsEvents;
|
|
508
|
+
const clauses: any[] = [
|
|
509
|
+
gte(table.timestamp, windowStart),
|
|
510
|
+
lte(table.timestamp, windowEnd),
|
|
511
|
+
];
|
|
512
|
+
if (rule.orgId) {
|
|
513
|
+
clauses.push(eq(table.orgId, rule.orgId));
|
|
514
|
+
} else {
|
|
515
|
+
clauses.push(isNull(table.orgId));
|
|
516
|
+
clauses.push(
|
|
517
|
+
sql`lower(${table.ownerEmail}) = ${rule.ownerEmail.toLowerCase()}`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
if (rule.eventName) clauses.push(eq(table.eventName, rule.eventName));
|
|
521
|
+
const rows: AnalyticsAlertEventRow[] = [];
|
|
522
|
+
const batchSize = analyticsAlertEventBatchSize();
|
|
523
|
+
let cursor: { timestamp: string; id: string } | null = null;
|
|
524
|
+
|
|
525
|
+
while (true) {
|
|
526
|
+
const pageClauses = [...clauses];
|
|
527
|
+
if (cursor) {
|
|
528
|
+
pageClauses.push(
|
|
529
|
+
sql`(${table.timestamp} < ${cursor.timestamp} or (${table.timestamp} = ${cursor.timestamp} and ${table.id} < ${cursor.id}))`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const page = await db
|
|
533
|
+
.select()
|
|
534
|
+
.from(table)
|
|
535
|
+
.where(and(...pageClauses))
|
|
536
|
+
.orderBy(desc(table.timestamp), desc(table.id))
|
|
537
|
+
.limit(batchSize);
|
|
538
|
+
|
|
539
|
+
if (!page.length) break;
|
|
540
|
+
rows.push(...page);
|
|
541
|
+
if (page.length < batchSize) break;
|
|
542
|
+
|
|
543
|
+
const last = page[page.length - 1];
|
|
544
|
+
cursor = { timestamp: last.timestamp, id: last.id };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return rows;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function analyticsAlertEventBatchSize(): number {
|
|
551
|
+
const raw =
|
|
552
|
+
process.env.ANALYTICS_ALERT_EVENT_BATCH_SIZE ??
|
|
553
|
+
process.env.ANALYTICS_ALERT_MAX_EVENTS_PER_RULE;
|
|
554
|
+
if (!raw) return 5000;
|
|
555
|
+
const parsed = Number.parseInt(raw, 10);
|
|
556
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10000) : 5000;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function isCoolingDown(rule: AnalyticsAlertRule, now: Date): boolean {
|
|
560
|
+
if (!rule.lastTriggeredAt || rule.cooldownMinutes <= 0) return false;
|
|
561
|
+
const last = Date.parse(rule.lastTriggeredAt);
|
|
562
|
+
if (!Number.isFinite(last)) return false;
|
|
563
|
+
return now.getTime() - last < rule.cooldownMinutes * 60 * 1000;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function markRuleStatus(
|
|
567
|
+
id: string,
|
|
568
|
+
patch: {
|
|
569
|
+
lastEvaluatedAt?: string;
|
|
570
|
+
lastTriggeredAt?: string;
|
|
571
|
+
lastStatus: AnalyticsAlertStatus;
|
|
572
|
+
lastError: string | null;
|
|
573
|
+
},
|
|
574
|
+
): Promise<void> {
|
|
575
|
+
const db = getDb() as any;
|
|
576
|
+
await db
|
|
577
|
+
.update(schema.analyticsAlertRules)
|
|
578
|
+
.set(patch)
|
|
579
|
+
.where(eq(schema.analyticsAlertRules.id, id));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function recordIncident(
|
|
583
|
+
rule: AnalyticsAlertRule,
|
|
584
|
+
input: AnalyticsAlertEvaluation & {
|
|
585
|
+
triggeredAt: string;
|
|
586
|
+
windowStart: string;
|
|
587
|
+
windowEnd: string;
|
|
588
|
+
notificationId?: string;
|
|
589
|
+
},
|
|
590
|
+
): Promise<void> {
|
|
591
|
+
const db = getDb() as any;
|
|
592
|
+
await db.insert(schema.analyticsAlertIncidents).values({
|
|
593
|
+
id: randomUUID(),
|
|
594
|
+
ruleId: rule.id,
|
|
595
|
+
triggeredAt: input.triggeredAt,
|
|
596
|
+
windowStart: input.windowStart,
|
|
597
|
+
windowEnd: input.windowEnd,
|
|
598
|
+
threshold: rule.threshold,
|
|
599
|
+
observedValue: input.observedValue,
|
|
600
|
+
eventCount: input.eventCount,
|
|
601
|
+
severity: rule.severity,
|
|
602
|
+
channels: JSON.stringify(rule.channels),
|
|
603
|
+
sampleEvents: JSON.stringify(input.sampleEvents),
|
|
604
|
+
notificationId: input.notificationId ?? null,
|
|
605
|
+
createdAt: input.triggeredAt,
|
|
606
|
+
ownerEmail: rule.ownerEmail,
|
|
607
|
+
orgId: rule.orgId,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function alertBody(
|
|
612
|
+
rule: AnalyticsAlertRule,
|
|
613
|
+
evaluation: AnalyticsAlertEvaluation,
|
|
614
|
+
): string {
|
|
615
|
+
const metric =
|
|
616
|
+
rule.thresholdMode === "distinct_count"
|
|
617
|
+
? `distinct ${rule.distinctBy || "user_key"} values`
|
|
618
|
+
: "events";
|
|
619
|
+
const target = rule.eventName ? ` for ${rule.eventName}` : "";
|
|
620
|
+
return `${evaluation.observedValue} ${metric}${target} matched in the last ${rule.windowMinutes} minutes; threshold is ${rule.threshold}.`;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function matchesFilter(
|
|
624
|
+
row: AnalyticsAlertEventRow,
|
|
625
|
+
filter: AnalyticsAlertFilter,
|
|
626
|
+
): boolean {
|
|
627
|
+
const value = fieldValue(row, filter.field);
|
|
628
|
+
const op = filter.op ?? "equals";
|
|
629
|
+
if (op === "exists") {
|
|
630
|
+
const exists = value !== undefined && value !== null && value !== "";
|
|
631
|
+
return filter.value === false ? !exists : exists;
|
|
632
|
+
}
|
|
633
|
+
if (op === "not_equals") return !valueEquals(value, filter.value);
|
|
634
|
+
if (op === "contains") return valueContains(value, filter.value);
|
|
635
|
+
if (op === "in") return valueIn(value, filter.value);
|
|
636
|
+
return valueEquals(value, filter.value);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function distinctCount(rows: AnalyticsAlertEventRow[], field: string): number {
|
|
640
|
+
const values = new Set<string>();
|
|
641
|
+
for (const row of rows) {
|
|
642
|
+
const value = fieldValue(row, field);
|
|
643
|
+
if (value === undefined || value === null || value === "") continue;
|
|
644
|
+
values.add(String(value));
|
|
645
|
+
}
|
|
646
|
+
return values.size;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function fieldValue(row: AnalyticsAlertEventRow, field: string): unknown {
|
|
650
|
+
const normalized = field.trim();
|
|
651
|
+
if (normalized.startsWith("properties.")) {
|
|
652
|
+
return pathValue(
|
|
653
|
+
safeJsonParse<Record<string, unknown>>(row.properties, {}),
|
|
654
|
+
normalized.slice("properties.".length),
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
if (normalized.startsWith("context.")) {
|
|
658
|
+
return pathValue(
|
|
659
|
+
safeJsonParse<Record<string, unknown>>(row.context, {}),
|
|
660
|
+
normalized.slice("context.".length),
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
const aliases: Record<string, keyof AnalyticsAlertEventRow> = {
|
|
664
|
+
id: "id",
|
|
665
|
+
event_name: "eventName",
|
|
666
|
+
eventName: "eventName",
|
|
667
|
+
user_id: "userId",
|
|
668
|
+
userId: "userId",
|
|
669
|
+
anonymous_id: "anonymousId",
|
|
670
|
+
anonymousId: "anonymousId",
|
|
671
|
+
user_key: "userKey",
|
|
672
|
+
userKey: "userKey",
|
|
673
|
+
session_id: "sessionId",
|
|
674
|
+
sessionId: "sessionId",
|
|
675
|
+
timestamp: "timestamp",
|
|
676
|
+
event_date: "eventDate",
|
|
677
|
+
eventDate: "eventDate",
|
|
678
|
+
received_at: "receivedAt",
|
|
679
|
+
receivedAt: "receivedAt",
|
|
680
|
+
url: "url",
|
|
681
|
+
path: "path",
|
|
682
|
+
hostname: "hostname",
|
|
683
|
+
referrer: "referrer",
|
|
684
|
+
app: "app",
|
|
685
|
+
template: "template",
|
|
686
|
+
signed_in: "signedIn",
|
|
687
|
+
signedIn: "signedIn",
|
|
688
|
+
};
|
|
689
|
+
const key = aliases[normalized];
|
|
690
|
+
return key ? row[key] : undefined;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function pathValue(obj: Record<string, unknown>, path: string): unknown {
|
|
694
|
+
let current: unknown = obj;
|
|
695
|
+
for (const part of path.split(".")) {
|
|
696
|
+
if (!part) return undefined;
|
|
697
|
+
if (!current || typeof current !== "object") return undefined;
|
|
698
|
+
current = (current as Record<string, unknown>)[part];
|
|
699
|
+
}
|
|
700
|
+
return current;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function valueEquals(actual: unknown, expected: unknown): boolean {
|
|
704
|
+
if (actual === expected) return true;
|
|
705
|
+
if (actual === undefined || actual === null) return false;
|
|
706
|
+
if (expected === undefined || expected === null) return false;
|
|
707
|
+
return String(actual) === String(expected);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function valueContains(actual: unknown, expected: unknown): boolean {
|
|
711
|
+
if (actual === undefined || actual === null) return false;
|
|
712
|
+
if (Array.isArray(actual)) {
|
|
713
|
+
return actual.some((item) => valueEquals(item, expected));
|
|
714
|
+
}
|
|
715
|
+
return String(actual).includes(String(expected ?? ""));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function valueIn(actual: unknown, expected: unknown): boolean {
|
|
719
|
+
if (!Array.isArray(expected)) return valueEquals(actual, expected);
|
|
720
|
+
return expected.some((item) => valueEquals(actual, item));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function sampleEvent(row: AnalyticsAlertEventRow): Record<string, unknown> {
|
|
724
|
+
return {
|
|
725
|
+
id: row.id,
|
|
726
|
+
eventName: row.eventName,
|
|
727
|
+
timestamp: row.timestamp,
|
|
728
|
+
app: row.app,
|
|
729
|
+
template: row.template,
|
|
730
|
+
userKey: row.userKey,
|
|
731
|
+
sessionId: row.sessionId,
|
|
732
|
+
path: row.path,
|
|
733
|
+
};
|
|
734
|
+
}
|