@agent-native/core 0.77.24 → 0.78.1

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 (212) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +20 -0
  3. package/corpus/core/docs/content/template-analytics.md +5 -0
  4. package/corpus/core/docs/content/tracking.md +45 -0
  5. package/corpus/core/package.json +3 -1
  6. package/corpus/core/src/agent/types.ts +22 -0
  7. package/corpus/core/src/client/AgentPanel.tsx +34 -18
  8. package/corpus/core/src/client/AssistantChat.tsx +40 -0
  9. package/corpus/core/src/client/agent-chat.ts +173 -0
  10. package/corpus/core/src/client/analytics.ts +141 -5
  11. package/corpus/core/src/client/composer/TiptapComposer.tsx +316 -60
  12. package/corpus/core/src/client/composer/extensions/MentionReference.tsx +3 -0
  13. package/corpus/core/src/client/composer/types.ts +22 -0
  14. package/corpus/core/src/client/index.ts +14 -0
  15. package/corpus/core/src/client/session-replay.ts +800 -0
  16. package/corpus/core/src/db/schema.ts +12 -0
  17. package/corpus/core/src/deploy/build.ts +75 -0
  18. package/corpus/core/src/private-blob/index.ts +18 -0
  19. package/corpus/core/src/private-blob/registry.ts +241 -0
  20. package/corpus/core/src/private-blob/types.ts +55 -0
  21. package/corpus/core/src/scripts/utils.ts +34 -6
  22. package/corpus/core/src/server/agent-chat-plugin.ts +10 -0
  23. package/corpus/core/src/server/email.ts +5 -0
  24. package/corpus/core/src/server/index.ts +1 -0
  25. package/corpus/core/src/styles/agent-native.css +24 -0
  26. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +1 -1
  27. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +43 -0
  28. package/corpus/templates/analytics/AGENTS.md +32 -1
  29. package/corpus/templates/analytics/DEVELOPING.md +9 -1
  30. package/corpus/templates/analytics/actions/delete-dashboard-report-subscription.ts +24 -0
  31. package/corpus/templates/analytics/actions/get-session-replay-events.ts +50 -0
  32. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +27 -0
  33. package/corpus/templates/analytics/actions/list-dashboard-report-subscriptions.ts +24 -0
  34. package/corpus/templates/analytics/actions/list-session-recordings.ts +59 -0
  35. package/corpus/templates/analytics/actions/navigate.ts +15 -5
  36. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +2 -2
  37. package/corpus/templates/analytics/actions/save-dashboard-report-subscription.ts +36 -0
  38. package/corpus/templates/analytics/actions/send-dashboard-report-now.ts +60 -0
  39. package/corpus/templates/analytics/actions/view-screen.ts +50 -2
  40. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +51 -2
  41. package/corpus/templates/analytics/app/components/layout/Header.tsx +4 -1
  42. package/corpus/templates/analytics/app/components/layout/Layout.tsx +14 -2
  43. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +6 -6
  44. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +35 -6
  45. package/corpus/templates/analytics/app/i18n-data.ts +1119 -0
  46. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/EmailReportDialog.tsx +437 -0
  47. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +11 -12
  48. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +43 -0
  49. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +136 -43
  50. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-filters.ts +26 -0
  51. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +450 -0
  52. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +419 -0
  53. package/corpus/templates/analytics/app/routes/overview.tsx +13 -6
  54. package/corpus/templates/analytics/app/routes/sessions.$recordingId.tsx +10 -0
  55. package/corpus/templates/analytics/app/routes/sessions._index.tsx +10 -0
  56. package/corpus/templates/analytics/changelog/2026-06-25-analytics-now-opens-directly-to-ask-instead-of-the-old-overv.md +6 -0
  57. package/corpus/templates/analytics/changelog/2026-06-25-dashboard-charts-now-show-reliable-blue-placement-lines-whil.md +6 -0
  58. package/corpus/templates/analytics/changelog/2026-06-26-dashboards-can-now-send-scheduled-daily-email-reports-from-t.md +6 -0
  59. package/corpus/templates/analytics/changelog/2026-06-26-session-replay-browser-sessions-can-now-be-reviewed-from.md +6 -0
  60. package/corpus/templates/analytics/package.json +5 -2
  61. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +131 -0
  62. package/corpus/templates/analytics/scripts/seed-session-replay.ts +198 -0
  63. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +140 -27
  64. package/corpus/templates/analytics/server/db/index.ts +12 -0
  65. package/corpus/templates/analytics/server/db/schema.ts +131 -0
  66. package/corpus/templates/analytics/server/handlers/session-replay.ts +281 -0
  67. package/corpus/templates/analytics/server/handlers/sql-query.ts +8 -348
  68. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +66 -0
  69. package/corpus/templates/analytics/server/jobs/session-replay-retention.ts +29 -0
  70. package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +365 -0
  71. package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +473 -0
  72. package/corpus/templates/analytics/server/lib/dashboard-report.ts +405 -0
  73. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +63 -18
  74. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +98 -26
  75. package/corpus/templates/analytics/server/lib/session-replay.ts +1645 -0
  76. package/corpus/templates/analytics/server/plugins/auth.ts +1 -1
  77. package/corpus/templates/analytics/server/plugins/dashboard-report-jobs.ts +47 -0
  78. package/corpus/templates/analytics/server/plugins/db.ts +200 -0
  79. package/corpus/templates/analytics/server/plugins/session-replay-retention-jobs.ts +41 -0
  80. package/corpus/templates/analytics/server/routes/api/analytics/replay.options.ts +1 -0
  81. package/corpus/templates/analytics/server/routes/api/analytics/replay.post.ts +1 -0
  82. package/corpus/templates/analytics/server/routes/api/dashboard-reports/run.post.ts +61 -0
  83. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId]/chunks/[seq].get.ts +1 -0
  84. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId]/events.get.ts +1 -0
  85. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId]/manifest.get.ts +1 -0
  86. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId].get.ts +1 -0
  87. package/corpus/templates/analytics/server/routes/api/session-replay/recordings.get.ts +1 -0
  88. package/corpus/templates/assets/.agents/skills/asset-generation/SKILL.md +10 -2
  89. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +14 -7
  90. package/corpus/templates/assets/.agents/skills/library-management/SKILL.md +12 -0
  91. package/corpus/templates/assets/AGENTS.md +18 -3
  92. package/corpus/templates/assets/actions/_image-model-default.ts +24 -0
  93. package/corpus/templates/assets/actions/dismiss-variant-slots.ts +15 -22
  94. package/corpus/templates/assets/actions/generate-asset.ts +30 -23
  95. package/corpus/templates/assets/actions/generate-image-batch.ts +76 -29
  96. package/corpus/templates/assets/actions/generate-image.ts +46 -30
  97. package/corpus/templates/assets/actions/generate-video.ts +18 -3
  98. package/corpus/templates/assets/actions/list-assets.ts +39 -8
  99. package/corpus/templates/assets/actions/list-libraries.ts +24 -2
  100. package/corpus/templates/assets/actions/navigate.ts +1 -1
  101. package/corpus/templates/assets/actions/open-asset-picker.ts +38 -1
  102. package/corpus/templates/assets/actions/refresh-generation-run.ts +10 -0
  103. package/corpus/templates/assets/actions/save-generated-image.ts +21 -19
  104. package/corpus/templates/assets/actions/variant-slots.ts +131 -28
  105. package/corpus/templates/assets/actions/view-screen.ts +9 -0
  106. package/corpus/templates/assets/app/components/generation/GenerationResults.tsx +425 -0
  107. package/corpus/templates/assets/app/components/layout/Header.tsx +3 -1
  108. package/corpus/templates/assets/app/components/layout/Layout.tsx +5 -0
  109. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +4 -6
  110. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +47 -21
  111. package/corpus/templates/assets/app/i18n-data.ts +1815 -0
  112. package/corpus/templates/assets/app/lib/libraries.ts +6 -0
  113. package/corpus/templates/assets/app/lib/picker-chat-handoff.ts +71 -0
  114. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  115. package/corpus/templates/assets/app/routes/asset.$id.tsx +2 -2
  116. package/corpus/templates/assets/app/routes/audit.tsx +1 -1
  117. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +1557 -2164
  118. package/corpus/templates/assets/app/routes/brand-kits._index.tsx +7 -191
  119. package/corpus/templates/assets/app/routes/brand-kits.tsx +1 -5
  120. package/corpus/templates/assets/app/routes/libraries.tsx +4 -4
  121. package/corpus/templates/assets/app/routes/library.$id.tsx +9 -7
  122. package/corpus/templates/assets/app/routes/library.tsx +1454 -175
  123. package/corpus/templates/assets/server/db/index.ts +1 -1
  124. package/corpus/templates/assets/server/lib/generation.ts +86 -15
  125. package/corpus/templates/assets/server/plugins/agent-chat.ts +196 -0
  126. package/corpus/templates/assets/server/plugins/db.ts +5 -0
  127. package/corpus/templates/assets/shared/api.ts +6 -3
  128. package/dist/agent/types.d.ts +21 -0
  129. package/dist/agent/types.d.ts.map +1 -1
  130. package/dist/agent/types.js.map +1 -1
  131. package/dist/client/AgentPanel.d.ts +6 -2
  132. package/dist/client/AgentPanel.d.ts.map +1 -1
  133. package/dist/client/AgentPanel.js +10 -4
  134. package/dist/client/AgentPanel.js.map +1 -1
  135. package/dist/client/AssistantChat.d.ts +6 -0
  136. package/dist/client/AssistantChat.d.ts.map +1 -1
  137. package/dist/client/AssistantChat.js +15 -3
  138. package/dist/client/AssistantChat.js.map +1 -1
  139. package/dist/client/agent-chat.d.ts +32 -0
  140. package/dist/client/agent-chat.d.ts.map +1 -1
  141. package/dist/client/agent-chat.js +113 -0
  142. package/dist/client/agent-chat.js.map +1 -1
  143. package/dist/client/analytics.d.ts +22 -3
  144. package/dist/client/analytics.d.ts.map +1 -1
  145. package/dist/client/analytics.js +96 -4
  146. package/dist/client/analytics.js.map +1 -1
  147. package/dist/client/composer/TiptapComposer.d.ts +2 -1
  148. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  149. package/dist/client/composer/TiptapComposer.js +228 -56
  150. package/dist/client/composer/TiptapComposer.js.map +1 -1
  151. package/dist/client/composer/extensions/MentionReference.d.ts.map +1 -1
  152. package/dist/client/composer/extensions/MentionReference.js +3 -0
  153. package/dist/client/composer/extensions/MentionReference.js.map +1 -1
  154. package/dist/client/composer/types.d.ts +21 -0
  155. package/dist/client/composer/types.d.ts.map +1 -1
  156. package/dist/client/composer/types.js.map +1 -1
  157. package/dist/client/index.d.ts +2 -2
  158. package/dist/client/index.d.ts.map +1 -1
  159. package/dist/client/index.js +2 -2
  160. package/dist/client/index.js.map +1 -1
  161. package/dist/client/session-replay.d.ts +41 -0
  162. package/dist/client/session-replay.d.ts.map +1 -0
  163. package/dist/client/session-replay.js +595 -0
  164. package/dist/client/session-replay.js.map +1 -0
  165. package/dist/collab/awareness.d.ts +2 -2
  166. package/dist/collab/awareness.d.ts.map +1 -1
  167. package/dist/collab/routes.d.ts +2 -2
  168. package/dist/db/schema.d.ts +3 -1
  169. package/dist/db/schema.d.ts.map +1 -1
  170. package/dist/db/schema.js +6 -2
  171. package/dist/db/schema.js.map +1 -1
  172. package/dist/deploy/build.d.ts +8 -0
  173. package/dist/deploy/build.d.ts.map +1 -1
  174. package/dist/deploy/build.js +66 -0
  175. package/dist/deploy/build.js.map +1 -1
  176. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  177. package/dist/notifications/routes.d.ts +3 -3
  178. package/dist/observability/routes.d.ts +8 -8
  179. package/dist/private-blob/index.d.ts +3 -0
  180. package/dist/private-blob/index.d.ts.map +1 -0
  181. package/dist/private-blob/index.js +2 -0
  182. package/dist/private-blob/index.js.map +1 -0
  183. package/dist/private-blob/registry.d.ts +10 -0
  184. package/dist/private-blob/registry.d.ts.map +1 -0
  185. package/dist/private-blob/registry.js +152 -0
  186. package/dist/private-blob/registry.js.map +1 -0
  187. package/dist/private-blob/types.d.ts +51 -0
  188. package/dist/private-blob/types.d.ts.map +1 -0
  189. package/dist/private-blob/types.js +2 -0
  190. package/dist/private-blob/types.js.map +1 -0
  191. package/dist/resources/handlers.d.ts +3 -3
  192. package/dist/scripts/utils.d.ts +4 -4
  193. package/dist/scripts/utils.d.ts.map +1 -1
  194. package/dist/scripts/utils.js +30 -6
  195. package/dist/scripts/utils.js.map +1 -1
  196. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  197. package/dist/server/agent-chat-plugin.js +5 -0
  198. package/dist/server/agent-chat-plugin.js.map +1 -1
  199. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  200. package/dist/server/email.d.ts +2 -0
  201. package/dist/server/email.d.ts.map +1 -1
  202. package/dist/server/email.js +3 -0
  203. package/dist/server/email.js.map +1 -1
  204. package/dist/server/index.d.ts +1 -1
  205. package/dist/server/index.d.ts.map +1 -1
  206. package/dist/server/index.js.map +1 -1
  207. package/dist/server/transcribe-voice.d.ts +1 -1
  208. package/dist/styles/agent-native.css +24 -0
  209. package/docs/content/template-analytics.md +5 -0
  210. package/docs/content/tracking.md +45 -0
  211. package/package.json +3 -1
  212. package/corpus/templates/analytics/app/pages/overview/OverviewPage.tsx +0 -209
@@ -0,0 +1,405 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ import {
4
+ getAppProductionUrl,
5
+ sendEmail,
6
+ signEmbedSessionToken,
7
+ } from "@agent-native/core/server";
8
+ import {
9
+ EMBED_MODE_QUERY_PARAM,
10
+ EMBED_TOKEN_QUERY_PARAM,
11
+ } from "@agent-native/core/shared";
12
+
13
+ import type {
14
+ DashboardFilter,
15
+ FilterType,
16
+ SqlDashboardConfig,
17
+ } from "../../app/pages/adhoc/sql-dashboard/types";
18
+ import {
19
+ getReportDashboard,
20
+ type AccessCtx,
21
+ type DashboardReportSubscription,
22
+ } from "./dashboard-report-subscriptions";
23
+
24
+ type ReportSnapshot = {
25
+ dashboardId: string;
26
+ title: string;
27
+ description?: string;
28
+ filters: Record<string, string>;
29
+ dashboardUrl: string;
30
+ generatedAt: string;
31
+ };
32
+
33
+ const DATE_FILTER_TYPES: ReadonlySet<FilterType> = new Set([
34
+ "date",
35
+ "date-range",
36
+ "toggle-date",
37
+ ]);
38
+ const DEFAULT_SERVERLESS_CHROMIUM_PACK_URL =
39
+ "https://github.com/Sparticuz/chromium/releases/download/v149.0.0/chromium-v149.0.0-pack.x64.tar";
40
+ const DASHBOARD_REPORT_SCREENSHOT_PARAM = "reportScreenshot";
41
+ const DASHBOARD_REPORT_CID = "dashboard-report-snapshot";
42
+
43
+ function daysAgo(n: number): string {
44
+ const d = new Date();
45
+ d.setDate(d.getDate() - n);
46
+ return d.toISOString().slice(0, 10);
47
+ }
48
+
49
+ function resolveDefault(raw: string | undefined, type: FilterType): string {
50
+ if (!raw) return "";
51
+ if (DATE_FILTER_TYPES.has(type)) {
52
+ const m = /^(\d+)d$/.exec(raw);
53
+ if (m) return daysAgo(parseInt(m[1], 10));
54
+ if (raw === "today") return daysAgo(0);
55
+ }
56
+ return raw;
57
+ }
58
+
59
+ function defaultFilterValues(
60
+ config: SqlDashboardConfig,
61
+ ): Record<string, string> {
62
+ const values: Record<string, string> = {};
63
+ const filters = Array.isArray(config.filters) ? config.filters : [];
64
+ for (const f of filters as DashboardFilter[]) {
65
+ if (f.type === "date-range") {
66
+ values[`f_${f.id}Start`] = resolveDefault(f.default, f.type);
67
+ values[`f_${f.id}End`] = daysAgo(0);
68
+ } else if (f.type !== "toggle" && f.type !== "toggle-date") {
69
+ values[`f_${f.id}`] = resolveDefault(f.default, f.type);
70
+ }
71
+ }
72
+ return Object.fromEntries(
73
+ Object.entries(values).filter(([, value]) => Boolean(value)),
74
+ );
75
+ }
76
+
77
+ function dashboardConfigFromRecord(raw: Record<string, unknown>) {
78
+ return {
79
+ name:
80
+ typeof raw.name === "string" && raw.name.trim()
81
+ ? raw.name.trim()
82
+ : "Untitled Dashboard",
83
+ description:
84
+ typeof raw.description === "string" ? raw.description : undefined,
85
+ filters: Array.isArray(raw.filters)
86
+ ? (raw.filters as DashboardFilter[])
87
+ : undefined,
88
+ variables:
89
+ raw.variables && typeof raw.variables === "object"
90
+ ? (raw.variables as Record<string, string>)
91
+ : undefined,
92
+ columns: typeof raw.columns === "number" ? raw.columns : undefined,
93
+ panels: Array.isArray(raw.panels) ? (raw.panels as any[]) : [],
94
+ } satisfies SqlDashboardConfig;
95
+ }
96
+
97
+ function dashboardBaseUrl(): string {
98
+ return (
99
+ process.env.DASHBOARD_REPORT_BASE_URL?.trim() ||
100
+ getAppProductionUrl().replace(/\/+$/, "")
101
+ );
102
+ }
103
+
104
+ function buildDashboardPath(
105
+ dashboardId: string,
106
+ filters: Record<string, string>,
107
+ options?: { reportScreenshot?: boolean },
108
+ ): string {
109
+ const url = new URL(
110
+ `/dashboards/${encodeURIComponent(dashboardId)}`,
111
+ "https://agent-native.invalid/",
112
+ );
113
+ for (const [key, value] of Object.entries(filters)) {
114
+ if (value) url.searchParams.set(key, value);
115
+ }
116
+ if (options?.reportScreenshot) {
117
+ url.searchParams.set(DASHBOARD_REPORT_SCREENSHOT_PARAM, "1");
118
+ }
119
+ return `${url.pathname}${url.search}`;
120
+ }
121
+
122
+ function buildDashboardUrl(
123
+ dashboardId: string,
124
+ filters: Record<string, string>,
125
+ options?: { reportScreenshot?: boolean },
126
+ ): string {
127
+ const path = buildDashboardPath(dashboardId, filters, options);
128
+ const url = new URL(path, `${dashboardBaseUrl()}/`);
129
+ return url.toString();
130
+ }
131
+
132
+ function escapeHtml(value: unknown): string {
133
+ return String(value ?? "")
134
+ .replace(/&/g, "&amp;")
135
+ .replace(/</g, "&lt;")
136
+ .replace(/>/g, "&gt;")
137
+ .replace(/"/g, "&quot;")
138
+ .replace(/'/g, "&#39;");
139
+ }
140
+
141
+ async function collectReportSnapshot(
142
+ sub: DashboardReportSubscription,
143
+ ): Promise<ReportSnapshot> {
144
+ const accessCtx: AccessCtx = {
145
+ email: sub.ownerEmail,
146
+ orgId: sub.orgId,
147
+ };
148
+ const dashboard = await getReportDashboard(sub.dashboardId, accessCtx);
149
+ if (!dashboard) {
150
+ throw Object.assign(new Error("Dashboard not found"), { statusCode: 404 });
151
+ }
152
+
153
+ const config = dashboardConfigFromRecord(dashboard.config);
154
+ const filters = {
155
+ ...defaultFilterValues(config),
156
+ ...sub.filters,
157
+ };
158
+
159
+ return {
160
+ dashboardId: sub.dashboardId,
161
+ title: config.name || dashboard.title,
162
+ description: config.description,
163
+ filters,
164
+ dashboardUrl: buildDashboardUrl(sub.dashboardId, filters),
165
+ generatedAt: new Date().toISOString(),
166
+ };
167
+ }
168
+
169
+ function isServerlessBrowserRuntime(): boolean {
170
+ return (
171
+ process.env.NETLIFY === "true" ||
172
+ Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME) ||
173
+ Boolean(process.env.AWS_EXECUTION_ENV)
174
+ );
175
+ }
176
+
177
+ function localChromiumExecutablePath(): string | null {
178
+ const configured =
179
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
180
+ process.env.CHROME_BIN ||
181
+ process.env.CHROMIUM_PATH;
182
+ if (configured && existsSync(configured)) return configured;
183
+
184
+ const candidates = [
185
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
186
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
187
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
188
+ "/usr/bin/google-chrome",
189
+ "/usr/bin/google-chrome-stable",
190
+ "/usr/bin/chromium",
191
+ "/usr/bin/chromium-browser",
192
+ ];
193
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
194
+ }
195
+
196
+ async function launchScreenshotBrowser() {
197
+ const { chromium: playwright } = await import("playwright-core");
198
+ const localExecutablePath = localChromiumExecutablePath();
199
+ if (localExecutablePath) {
200
+ return playwright.launch({
201
+ executablePath: localExecutablePath,
202
+ headless: true,
203
+ });
204
+ }
205
+
206
+ if (isServerlessBrowserRuntime()) {
207
+ const { default: chromium } = await import("@sparticuz/chromium-min");
208
+ const packUrl =
209
+ process.env.DASHBOARD_REPORT_CHROMIUM_PACK_URL?.trim() ||
210
+ DEFAULT_SERVERLESS_CHROMIUM_PACK_URL;
211
+ return playwright.launch({
212
+ args: chromium.args,
213
+ executablePath: await chromium.executablePath(packUrl),
214
+ headless: true,
215
+ });
216
+ }
217
+
218
+ return playwright.launch({ headless: true });
219
+ }
220
+
221
+ async function waitForDashboardReportReady(page: any): Promise<void> {
222
+ await page.waitForFunction(
223
+ `(() => {
224
+ const root = document.querySelector("[data-dashboard-report-capture]");
225
+ if (!root) return false;
226
+ if (root.getAttribute("data-dashboard-report-ready") !== "true") {
227
+ return false;
228
+ }
229
+ return !root.querySelector("[data-dashboard-report-loading='true']");
230
+ })()`,
231
+ undefined,
232
+ { timeout: 90_000 },
233
+ );
234
+ await page.evaluate(`(async () => {
235
+ await document.fonts?.ready;
236
+ })()`);
237
+ await page.waitForTimeout(750);
238
+ }
239
+
240
+ async function scrollDashboardForLazyRendering(page: any): Promise<void> {
241
+ await page.evaluate(`(async () => {
242
+ const wait = (ms) =>
243
+ new Promise((resolve) => window.setTimeout(resolve, ms));
244
+ const maxY = Math.max(
245
+ document.body.scrollHeight,
246
+ document.documentElement.scrollHeight,
247
+ );
248
+ const step = Math.max(600, Math.floor(window.innerHeight * 0.75));
249
+ for (let y = 0; y < maxY; y += step) {
250
+ window.scrollTo(0, y);
251
+ await wait(120);
252
+ }
253
+ window.scrollTo(0, 0);
254
+ })()`);
255
+ }
256
+
257
+ async function captureDashboardPng(
258
+ sub: DashboardReportSubscription,
259
+ snapshot: ReportSnapshot,
260
+ ): Promise<Buffer> {
261
+ const targetPath = buildDashboardPath(
262
+ snapshot.dashboardId,
263
+ snapshot.filters,
264
+ {
265
+ reportScreenshot: true,
266
+ },
267
+ );
268
+ const token = signEmbedSessionToken({
269
+ ownerEmail: sub.ownerEmail,
270
+ orgId: sub.orgId,
271
+ targetPath,
272
+ scope: `dashboard-report-screenshot:${sub.id}`,
273
+ ttlSeconds: 5 * 60,
274
+ });
275
+ const screenshotUrl = new URL(targetPath, `${dashboardBaseUrl()}/`);
276
+ screenshotUrl.searchParams.set(EMBED_MODE_QUERY_PARAM, "1");
277
+ screenshotUrl.searchParams.set(EMBED_TOKEN_QUERY_PARAM, token);
278
+
279
+ const browser = await launchScreenshotBrowser();
280
+ try {
281
+ const page = await browser.newPage({
282
+ viewport: { width: 1440, height: 1800 },
283
+ deviceScaleFactor: 1,
284
+ });
285
+ page.setDefaultTimeout(90_000);
286
+ await page.emulateMedia({ media: "screen" });
287
+ await page.goto(screenshotUrl.toString(), {
288
+ waitUntil: "domcontentloaded",
289
+ timeout: 90_000,
290
+ });
291
+
292
+ const capture = page.locator("[data-dashboard-report-capture]");
293
+ await capture.waitFor({ state: "visible", timeout: 90_000 });
294
+ await waitForDashboardReportReady(page);
295
+ await scrollDashboardForLazyRendering(page);
296
+ await waitForDashboardReportReady(page);
297
+
298
+ const box = await capture.boundingBox();
299
+ if (box) {
300
+ await page.setViewportSize({
301
+ width: Math.max(1200, Math.min(1800, Math.ceil(box.width + 64))),
302
+ height: Math.max(1000, Math.min(7000, Math.ceil(box.height + 64))),
303
+ });
304
+ }
305
+ await capture.scrollIntoViewIfNeeded();
306
+ const image = await capture.screenshot({
307
+ type: "png",
308
+ animations: "disabled",
309
+ });
310
+ if (!image?.length) {
311
+ throw new Error("Dashboard screenshot was empty");
312
+ }
313
+ return Buffer.from(image);
314
+ } finally {
315
+ await browser.close();
316
+ }
317
+ }
318
+
319
+ function reportDate(snapshot: ReportSnapshot): string {
320
+ return new Date(snapshot.generatedAt).toLocaleDateString("en-US", {
321
+ year: "numeric",
322
+ month: "numeric",
323
+ day: "numeric",
324
+ });
325
+ }
326
+
327
+ function renderReportEmailHtml(snapshot: ReportSnapshot): string {
328
+ const title = escapeHtml(snapshot.title);
329
+ const dashboardUrl = escapeHtml(snapshot.dashboardUrl);
330
+ const date = escapeHtml(reportDate(snapshot));
331
+ const description = snapshot.description
332
+ ? `<p style="margin:8px 0 18px;color:#525866;font-size:14px;line-height:1.5;">${escapeHtml(snapshot.description)}</p>`
333
+ : "";
334
+
335
+ return `<!doctype html>
336
+ <html>
337
+ <body style="margin:0;padding:24px;background:#ffffff;color:#171717;font-family:Inter,Arial,sans-serif;">
338
+ <h3 style="margin:0 0 8px;font-size:18px;line-height:1.35;font-weight:600;">
339
+ Here's the report of <a href="${dashboardUrl}" style="color:#2563eb;text-decoration:none;">${title}</a> for ${date}.
340
+ </h3>
341
+ ${description}
342
+ <a href="${dashboardUrl}" style="display:block;text-decoration:none;">
343
+ <img src="cid:${DASHBOARD_REPORT_CID}" alt="${title}" width="100%" style="display:block;width:100%;max-width:1280px;height:auto;border:1px solid #e5e7eb;border-radius:8px;" />
344
+ </a>
345
+ <p style="margin:18px 0 0;color:#525866;font-size:13px;line-height:1.45;">
346
+ <a href="${dashboardUrl}" style="color:#2563eb;text-decoration:none;">Open dashboard</a>
347
+ </p>
348
+ </body>
349
+ </html>`;
350
+ }
351
+
352
+ function renderReportText(snapshot: ReportSnapshot): string {
353
+ return [
354
+ `Daily dashboard report: ${snapshot.title}`,
355
+ `Date: ${reportDate(snapshot)}`,
356
+ `Open dashboard: ${snapshot.dashboardUrl}`,
357
+ ].join("\n");
358
+ }
359
+
360
+ function reportFilename(title: string): string {
361
+ const slug = title
362
+ .toLowerCase()
363
+ .replace(/[^a-z0-9]+/g, "-")
364
+ .replace(/^-|-$/g, "")
365
+ .slice(0, 64);
366
+ return `${slug || "dashboard"}-report.png`;
367
+ }
368
+
369
+ export async function sendDashboardReportSubscription(
370
+ sub: DashboardReportSubscription,
371
+ ): Promise<{
372
+ dashboardUrl: string;
373
+ recipientCount: number;
374
+ screenshotAttached: boolean;
375
+ }> {
376
+ const snapshot = await collectReportSnapshot(sub);
377
+ const png = await captureDashboardPng(sub, snapshot);
378
+ const html = renderReportEmailHtml(snapshot);
379
+ const text = renderReportText(snapshot);
380
+ const subject = `Daily dashboard: ${snapshot.title}`;
381
+
382
+ for (const to of sub.recipients) {
383
+ await sendEmail({
384
+ to,
385
+ subject,
386
+ html,
387
+ text,
388
+ attachments: [
389
+ {
390
+ filename: reportFilename(snapshot.title),
391
+ content: png,
392
+ contentType: "image/png",
393
+ contentId: DASHBOARD_REPORT_CID,
394
+ disposition: "inline",
395
+ },
396
+ ],
397
+ });
398
+ }
399
+
400
+ return {
401
+ dashboardUrl: snapshot.dashboardUrl,
402
+ recipientCount: sub.recipients.length,
403
+ screenshotAttached: true,
404
+ };
405
+ }
@@ -25,6 +25,10 @@ export interface AnalyticsQueryResult {
25
25
 
26
26
  const MAX_EVENTS_PER_REQUEST = 100;
27
27
  const MAX_QUERY_ROWS = 5_000;
28
+ const FIRST_PARTY_QUERY_TABLES = new Set([
29
+ "analytics_events",
30
+ "session_recordings",
31
+ ]);
28
32
  const RESERVED_ALIAS_WORDS = new Set([
29
33
  "where",
30
34
  "on",
@@ -75,6 +79,9 @@ export async function createAnalyticsPublicKey(
75
79
  name: name.trim() || "Default key",
76
80
  publicKey,
77
81
  publicKeyPrefix: publicKey.slice(0, 13),
82
+ replayAllowedOrigins: "[]",
83
+ replayMaxBytesPerDay: 100 * 1024 * 1024,
84
+ replayMaxRequestsPerMinute: 120,
78
85
  createdAt,
79
86
  ownerEmail: scope.userEmail,
80
87
  orgId: scope.orgId,
@@ -85,6 +92,9 @@ export async function createAnalyticsPublicKey(
85
92
  name: row.name,
86
93
  publicKey,
87
94
  publicKeyPrefix: row.publicKeyPrefix,
95
+ replayAllowedOrigins: [],
96
+ replayMaxBytesPerDay: row.replayMaxBytesPerDay,
97
+ replayMaxRequestsPerMinute: row.replayMaxRequestsPerMinute,
88
98
  createdAt,
89
99
  orgId: row.orgId,
90
100
  revokedAt: null,
@@ -118,6 +128,9 @@ export async function listAnalyticsPublicKeys(
118
128
  id: row.id,
119
129
  name: row.name,
120
130
  publicKeyPrefix: row.publicKeyPrefix,
131
+ replayAllowedOrigins: parseReplayAllowedOrigins(row.replayAllowedOrigins),
132
+ replayMaxBytesPerDay: row.replayMaxBytesPerDay ?? 100 * 1024 * 1024,
133
+ replayMaxRequestsPerMinute: row.replayMaxRequestsPerMinute ?? 120,
121
134
  createdAt: row.createdAt,
122
135
  lastUsedAt: row.lastUsedAt ?? null,
123
136
  revokedAt: row.revokedAt ?? null,
@@ -125,6 +138,25 @@ export async function listAnalyticsPublicKeys(
125
138
  }));
126
139
  }
127
140
 
141
+ function parseReplayAllowedOrigins(value: unknown): string[] {
142
+ if (Array.isArray(value)) {
143
+ return value.filter((item): item is string => typeof item === "string");
144
+ }
145
+ if (typeof value !== "string" || !value.trim()) return [];
146
+ try {
147
+ const parsed = JSON.parse(value);
148
+ if (Array.isArray(parsed)) {
149
+ return parsed.filter((item): item is string => typeof item === "string");
150
+ }
151
+ } catch {
152
+ return value
153
+ .split(/[\n,]/)
154
+ .map((item) => item.trim())
155
+ .filter(Boolean);
156
+ }
157
+ return [];
158
+ }
159
+
128
160
  export async function revokeAnalyticsPublicKey(
129
161
  scope: AnalyticsScope,
130
162
  keyId: string,
@@ -435,6 +467,11 @@ export function validateFirstPartyAnalyticsSql(sql: string): void {
435
467
  if (/\$\d+\b/.test(stripped)) {
436
468
  throw new Error("Bind placeholders are not supported in dashboard SQL");
437
469
  }
470
+ if (/\bsession_replay_chunks\b/i.test(stripped)) {
471
+ throw new Error(
472
+ "First-party analytics queries cannot read session replay chunks",
473
+ );
474
+ }
438
475
 
439
476
  const cteNames = new Set<string>();
440
477
  const cteRe = /\b([a-zA-Z_][a-zA-Z0-9_]*)\s+as\s*\(/gi;
@@ -442,22 +479,24 @@ export function validateFirstPartyAnalyticsSql(sql: string): void {
442
479
  cteNames.add(match[1].toLowerCase());
443
480
  }
444
481
 
445
- let usesAnalyticsEvents = false;
482
+ let usesAllowedTable = false;
446
483
  const tableRe =
447
484
  /\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?)/gi;
448
485
  for (const match of stripped.matchAll(tableRe)) {
449
486
  const ref = match[1].toLowerCase();
450
- if (ref === "analytics_events") {
451
- usesAnalyticsEvents = true;
487
+ if (FIRST_PARTY_QUERY_TABLES.has(ref)) {
488
+ usesAllowedTable = true;
452
489
  continue;
453
490
  }
454
491
  if (cteNames.has(ref)) continue;
455
492
  throw new Error(
456
- `First-party analytics queries can only read analytics_events (found ${match[1]})`,
493
+ `First-party analytics queries can only read analytics_events or session_recordings (found ${match[1]})`,
457
494
  );
458
495
  }
459
- if (!usesAnalyticsEvents) {
460
- throw new Error("Query must read from analytics_events");
496
+ if (!usesAllowedTable) {
497
+ throw new Error(
498
+ "Query must read from analytics_events or session_recordings",
499
+ );
461
500
  }
462
501
  }
463
502
 
@@ -483,18 +522,24 @@ function scopedAnalyticsSql(
483
522
  ): { sql: string; args: Array<string | null> } {
484
523
  const args: Array<string | null> = [];
485
524
  const aliasRe =
486
- /\b(from|join)\s+analytics_events\b(\s+(?:as\s+)?(?!where\b|on\b|group\b|order\b|limit\b|join\b|left\b|right\b|inner\b|outer\b|cross\b|full\b|having\b|union\b)([a-zA-Z_][a-zA-Z0-9_]*))?/gi;
487
- const rewritten = sql.replace(aliasRe, (full, keyword, aliasPart, alias) => {
488
- const normalizedAlias =
489
- typeof alias === "string" ? alias.toLowerCase() : "";
490
- const usableAlias =
491
- aliasPart && normalizedAlias && !RESERVED_ALIAS_WORDS.has(normalizedAlias)
492
- ? aliasPart
493
- : " AS analytics_events";
494
- const scopeDef = scopeClause(scope);
495
- args.push(...scopeDef.args);
496
- return `${keyword} (SELECT * FROM analytics_events WHERE ${scopeDef.sql})${usableAlias}`;
497
- });
525
+ /\b(from|join)\s+(analytics_events|session_recordings)\b(\s+(?:as\s+)?(?!where\b|on\b|group\b|order\b|limit\b|join\b|left\b|right\b|inner\b|outer\b|cross\b|full\b|having\b|union\b)([a-zA-Z_][a-zA-Z0-9_]*))?/gi;
526
+ const rewritten = sql.replace(
527
+ aliasRe,
528
+ (full, keyword, tableName, aliasPart, alias) => {
529
+ const normalizedTable = String(tableName).toLowerCase();
530
+ const normalizedAlias =
531
+ typeof alias === "string" ? alias.toLowerCase() : "";
532
+ const usableAlias =
533
+ aliasPart &&
534
+ normalizedAlias &&
535
+ !RESERVED_ALIAS_WORDS.has(normalizedAlias)
536
+ ? aliasPart
537
+ : ` AS ${normalizedTable}`;
538
+ const scopeDef = scopeClause(scope);
539
+ args.push(...scopeDef.args);
540
+ return `${keyword} (SELECT * FROM ${normalizedTable} WHERE ${scopeDef.sql})${usableAlias}`;
541
+ },
542
+ );
498
543
  return { sql: rewritten, args };
499
544
  }
500
545