@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,1645 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { gzipSync, gunzipSync } from "node:zlib";
4
+
5
+ import {
6
+ deletePrivateBlob,
7
+ putPrivateBlob,
8
+ readPrivateBlob,
9
+ type PrivateBlobHandle,
10
+ } from "@agent-native/core/private-blob";
11
+ import { recordChange } from "@agent-native/core/server";
12
+ import {
13
+ accessFilter,
14
+ resolveAccess,
15
+ roleSatisfies,
16
+ type ShareRole,
17
+ } from "@agent-native/core/sharing";
18
+ import { and, asc, desc, eq, gte, isNull, lt, lte, or, sql } from "drizzle-orm";
19
+
20
+ import { getDb, schema } from "../db/index.js";
21
+ import { resolveAnalyticsEventDimensions } from "./first-party-analytics.js";
22
+
23
+ export type ReplayRange = "24h" | "7d" | "30d" | "90d" | "all";
24
+
25
+ export interface ReplayScope {
26
+ userEmail: string;
27
+ orgId: string | null;
28
+ }
29
+
30
+ function rangeStartIso(range: ReplayRange): string | null {
31
+ if (range === "all") return null;
32
+ const hours =
33
+ range === "24h"
34
+ ? 24
35
+ : range === "7d"
36
+ ? 24 * 7
37
+ : range === "30d"
38
+ ? 24 * 30
39
+ : 24 * 90;
40
+ return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
41
+ }
42
+
43
+ export function replayRangeToIso(range: ReplayRange): string | null {
44
+ return rangeStartIso(range);
45
+ }
46
+
47
+ export type SessionReplayScope = ReplayScope;
48
+ export type SessionReplayAccessRole = "owner" | ShareRole;
49
+
50
+ export interface SessionReplayListFilters {
51
+ query?: string;
52
+ app?: string;
53
+ template?: string;
54
+ sessionId?: string;
55
+ userId?: string;
56
+ anonymousId?: string;
57
+ path?: string;
58
+ from?: string;
59
+ to?: string;
60
+ minDurationMs?: number;
61
+ hasErrors?: boolean;
62
+ hasRageClicks?: boolean;
63
+ status?: "active" | "completed";
64
+ limit?: number;
65
+ }
66
+
67
+ export interface SessionReplayEventReadOptions {
68
+ startSeq?: number;
69
+ endSeq?: number;
70
+ limit?: number;
71
+ }
72
+
73
+ export interface NormalizedSessionReplayChunk {
74
+ seq: number;
75
+ checksum: string;
76
+ byteLength: number;
77
+ eventCount: number;
78
+ startedAt: string | null;
79
+ endedAt: string | null;
80
+ storageKind: "inline" | "blob";
81
+ storageRef: string | null;
82
+ inlineData: string | null;
83
+ }
84
+
85
+ export interface ParsedSessionReplayIngest {
86
+ publicKey: string;
87
+ clientRecordingId: string;
88
+ sessionId: string;
89
+ userId: string | null;
90
+ anonymousId: string | null;
91
+ userKey: string | null;
92
+ startedAt: string;
93
+ endedAt: string | null;
94
+ durationMs: number | null;
95
+ url: string | null;
96
+ path: string | null;
97
+ hostname: string | null;
98
+ referrer: string | null;
99
+ app: string | null;
100
+ template: string | null;
101
+ pageCount: number;
102
+ errorCount: number;
103
+ rageClickCount: number;
104
+ privacyMode: string;
105
+ status: "active" | "completed";
106
+ metadata: Record<string, unknown>;
107
+ chunks: NormalizedSessionReplayChunk[];
108
+ }
109
+
110
+ export interface SessionRecordingSummary {
111
+ id: string;
112
+ clientRecordingId: string;
113
+ sessionId: string;
114
+ userId: string | null;
115
+ anonymousId: string | null;
116
+ userKey: string | null;
117
+ startedAt: string;
118
+ endedAt: string | null;
119
+ durationMs: number | null;
120
+ chunkCount: number;
121
+ eventCount: number;
122
+ totalBytes: number;
123
+ pageCount: number;
124
+ errorCount: number;
125
+ rageClickCount: number;
126
+ privacyMode: string;
127
+ firstUrl: string | null;
128
+ lastUrl: string | null;
129
+ path: string | null;
130
+ hostname: string | null;
131
+ referrer: string | null;
132
+ app: string | null;
133
+ template: string | null;
134
+ status: "active" | "completed";
135
+ metadata: Record<string, unknown>;
136
+ ownerEmail: string;
137
+ orgId: string | null;
138
+ visibility: "private" | "org" | "public";
139
+ createdAt: string;
140
+ updatedAt: string;
141
+ lastIngestedAt: string | null;
142
+ role?: SessionReplayAccessRole;
143
+ canEdit?: boolean;
144
+ canManage?: boolean;
145
+ }
146
+
147
+ const MAX_REPLAY_CHUNKS_PER_REQUEST = 20;
148
+ const MAX_REPLAY_CHUNKS_PER_RECORDING = 2_000;
149
+ const MAX_INLINE_REPLAY_CHUNK_BYTES = 256 * 1024;
150
+ const MAX_BLOB_REPLAY_CHUNK_BYTES = 5 * 1024 * 1024;
151
+ const MAX_REPLAY_BLOB_REF_LENGTH = 16 * 1024;
152
+ const MAX_REPLAY_METADATA_BYTES = 16 * 1024;
153
+ const MAX_REPLAY_EVENTS_PER_CHUNK = 1_000;
154
+ const MAX_REPLAY_EVENTS_READ = 10_000;
155
+ const MAX_REPLAY_EVENTS_RESPONSE_BYTES = 2 * 1024 * 1024;
156
+ const DEFAULT_SESSION_RECORDINGS_LIMIT = 50;
157
+ const MAX_SESSION_RECORDINGS_LIMIT = 100;
158
+ const DEFAULT_REPLAY_RETENTION_DAYS = 30;
159
+ const DEFAULT_ABANDONED_REPLAY_MINUTES = 30;
160
+ const DEFAULT_REPLAY_MAX_BYTES_PER_DAY = 100 * 1024 * 1024;
161
+ const DEFAULT_REPLAY_MAX_REQUESTS_PER_MINUTE = 120;
162
+ const RETENTION_DELETE_BATCH_SIZE = 500;
163
+ const REPLAY_PRIVATE_BLOB_REF_KIND = "agent-native.session-replay.private-blob";
164
+ const REPLAY_PRIVATE_BLOB_REF_VERSION = 1;
165
+ let inlineReplayFallbackWarned = false;
166
+
167
+ function replayError(message: string, statusCode: number): Error {
168
+ return Object.assign(new Error(message), { statusCode });
169
+ }
170
+
171
+ function replayNowIso(): string {
172
+ return new Date().toISOString();
173
+ }
174
+
175
+ function replayId(prefix: string): string {
176
+ return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
177
+ }
178
+
179
+ function replaySha256(value: string): string {
180
+ return createHash("sha256").update(value).digest("hex");
181
+ }
182
+
183
+ function replayRecord(value: unknown): Record<string, unknown> {
184
+ return value && typeof value === "object" && !Array.isArray(value)
185
+ ? (value as Record<string, unknown>)
186
+ : {};
187
+ }
188
+
189
+ function replayString(value: unknown): string | null {
190
+ if (typeof value === "string" && value.trim()) return value.trim();
191
+ if (typeof value === "number" || typeof value === "boolean") {
192
+ return String(value);
193
+ }
194
+ return null;
195
+ }
196
+
197
+ function replayInteger(value: unknown): number | null {
198
+ if (typeof value === "number" && Number.isInteger(value)) return value;
199
+ if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
200
+ return Number(value);
201
+ }
202
+ return null;
203
+ }
204
+
205
+ function normalizeReplayOrigin(
206
+ value: string | null | undefined,
207
+ ): string | null {
208
+ if (!value) return null;
209
+ try {
210
+ return new URL(value).origin;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ function parseAllowedReplayOrigins(value: unknown): string[] {
217
+ if (Array.isArray(value)) {
218
+ return value
219
+ .map((item) => normalizeReplayOrigin(replayString(item)))
220
+ .filter((item): item is string => Boolean(item));
221
+ }
222
+ if (typeof value !== "string" || !value.trim()) return [];
223
+ try {
224
+ return parseAllowedReplayOrigins(JSON.parse(value));
225
+ } catch {
226
+ return value
227
+ .split(/[\n,]/)
228
+ .map((item) => normalizeReplayOrigin(item.trim()))
229
+ .filter((item): item is string => Boolean(item));
230
+ }
231
+ }
232
+
233
+ function positiveReplayLimit(value: unknown, fallback: number): number {
234
+ const parsed = replayInteger(value);
235
+ return parsed && parsed > 0 ? parsed : fallback;
236
+ }
237
+
238
+ function replayIngestByteLength(
239
+ input: ParsedSessionReplayIngest,
240
+ context: SessionReplayIngestContext,
241
+ ): number {
242
+ const requestBytes = Number(context.requestBytes ?? 0);
243
+ if (Number.isFinite(requestBytes) && requestBytes > 0) {
244
+ return Math.ceil(requestBytes);
245
+ }
246
+ return input.chunks.reduce(
247
+ (sum, chunk) => sum + Math.max(0, Number(chunk.byteLength ?? 0)),
248
+ 0,
249
+ );
250
+ }
251
+
252
+ function replayTimestamp(value: unknown): string | null {
253
+ if (value instanceof Date) return value.toISOString();
254
+ if (typeof value === "number" || typeof value === "string") {
255
+ const d = new Date(value);
256
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
257
+ }
258
+ return null;
259
+ }
260
+
261
+ function replayMinIso(values: Array<string | null | undefined>): string | null {
262
+ const present = values.filter((value): value is string => Boolean(value));
263
+ if (!present.length) return null;
264
+ return present.reduce((min, value) => (value < min ? value : min));
265
+ }
266
+
267
+ function replayMaxIso(values: Array<string | null | undefined>): string | null {
268
+ const present = values.filter((value): value is string => Boolean(value));
269
+ if (!present.length) return null;
270
+ return present.reduce((max, value) => (value > max ? value : max));
271
+ }
272
+
273
+ function normalizeReplayUrl(url: string | null): {
274
+ url: string | null;
275
+ path: string | null;
276
+ hostname: string | null;
277
+ } {
278
+ if (!url) return { url: null, path: null, hostname: null };
279
+ try {
280
+ const parsed = new URL(url, "https://placeholder.agent-native.local");
281
+ const relative = !/^https?:\/\//i.test(url);
282
+ return {
283
+ url: relative ? `${parsed.pathname}${parsed.search}${parsed.hash}` : url,
284
+ path: parsed.pathname,
285
+ hostname: relative ? null : parsed.hostname,
286
+ };
287
+ } catch {
288
+ return { url, path: null, hostname: null };
289
+ }
290
+ }
291
+
292
+ function assertReplayMetadataCap(value: Record<string, unknown>): void {
293
+ if (
294
+ Buffer.byteLength(JSON.stringify(value), "utf8") > MAX_REPLAY_METADATA_BYTES
295
+ ) {
296
+ throw replayError(
297
+ `Replay metadata must be ${MAX_REPLAY_METADATA_BYTES} bytes or smaller`,
298
+ 413,
299
+ );
300
+ }
301
+ }
302
+
303
+ function normalizeReplayInlineData(raw: Record<string, unknown>): {
304
+ inlineData: string | null;
305
+ eventCount: number | null;
306
+ } {
307
+ if (Array.isArray(raw.events)) {
308
+ if (raw.events.length > MAX_REPLAY_EVENTS_PER_CHUNK) {
309
+ throw replayError(
310
+ `Replay chunks may contain at most ${MAX_REPLAY_EVENTS_PER_CHUNK} events`,
311
+ 413,
312
+ );
313
+ }
314
+ return {
315
+ inlineData: JSON.stringify(raw.events),
316
+ eventCount: raw.events.length,
317
+ };
318
+ }
319
+ if (raw.data !== undefined) {
320
+ return {
321
+ inlineData:
322
+ typeof raw.data === "string" ? raw.data : JSON.stringify(raw.data),
323
+ eventCount: null,
324
+ };
325
+ }
326
+ if (raw.payload !== undefined) {
327
+ return { inlineData: JSON.stringify(raw.payload), eventCount: null };
328
+ }
329
+ return { inlineData: null, eventCount: null };
330
+ }
331
+
332
+ function inferReplayEventCount(inlineData: string | null): number | null {
333
+ if (!inlineData) return null;
334
+ try {
335
+ const parsed = JSON.parse(inlineData);
336
+ if (Array.isArray(parsed)) return parsed.length;
337
+ if (Array.isArray(parsed?.events)) return parsed.events.length;
338
+ } catch {
339
+ return null;
340
+ }
341
+ return null;
342
+ }
343
+
344
+ function normalizeReplayBlobRef(value: unknown): string | null {
345
+ const ref = replayString(value);
346
+ if (!ref) return null;
347
+ if (ref.length > MAX_REPLAY_BLOB_REF_LENGTH) {
348
+ throw replayError(
349
+ `Replay blob references must be ${MAX_REPLAY_BLOB_REF_LENGTH} characters or shorter`,
350
+ 413,
351
+ );
352
+ }
353
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(ref)) {
354
+ throw replayError(
355
+ "Replay blob references must be private storage refs, not provider URLs",
356
+ 400,
357
+ );
358
+ }
359
+ return ref;
360
+ }
361
+
362
+ interface StoredReplayBlobRef {
363
+ kind: typeof REPLAY_PRIVATE_BLOB_REF_KIND;
364
+ version: typeof REPLAY_PRIVATE_BLOB_REF_VERSION;
365
+ compression: "gzip";
366
+ handle: PrivateBlobHandle;
367
+ }
368
+
369
+ function encodeReplayBlobRef(handle: PrivateBlobHandle): string {
370
+ return JSON.stringify({
371
+ kind: REPLAY_PRIVATE_BLOB_REF_KIND,
372
+ version: REPLAY_PRIVATE_BLOB_REF_VERSION,
373
+ compression: "gzip",
374
+ handle,
375
+ } satisfies StoredReplayBlobRef);
376
+ }
377
+
378
+ function decodeReplayBlobRef(value: string | null): StoredReplayBlobRef | null {
379
+ if (!value) return null;
380
+ try {
381
+ const parsed = JSON.parse(value) as StoredReplayBlobRef;
382
+ if (
383
+ parsed?.kind !== REPLAY_PRIVATE_BLOB_REF_KIND ||
384
+ parsed.version !== REPLAY_PRIVATE_BLOB_REF_VERSION ||
385
+ parsed.compression !== "gzip" ||
386
+ !parsed.handle?.opaque
387
+ ) {
388
+ return null;
389
+ }
390
+ return parsed;
391
+ } catch {
392
+ return null;
393
+ }
394
+ }
395
+
396
+ function parsePositiveIntegerEnv(name: string, fallback: number): number {
397
+ const raw = process.env[name];
398
+ if (!raw) return fallback;
399
+ const parsed = Number(raw);
400
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
401
+ return Math.floor(parsed);
402
+ }
403
+
404
+ function isoBefore(date: Date, milliseconds: number): string {
405
+ return new Date(date.getTime() - milliseconds).toISOString();
406
+ }
407
+
408
+ function replayRetentionDays(): number {
409
+ return parsePositiveIntegerEnv(
410
+ "ANALYTICS_SESSION_REPLAY_RETENTION_DAYS",
411
+ DEFAULT_REPLAY_RETENTION_DAYS,
412
+ );
413
+ }
414
+
415
+ function abandonedReplayMinutes(): number {
416
+ return parsePositiveIntegerEnv(
417
+ "ANALYTICS_SESSION_REPLAY_ABANDONED_MINUTES",
418
+ DEFAULT_ABANDONED_REPLAY_MINUTES,
419
+ );
420
+ }
421
+
422
+ function productionInlineFallbackAllowed(): boolean {
423
+ if (process.env.NODE_ENV !== "production") return true;
424
+ return process.env.ANALYTICS_SESSION_REPLAY_SQL_FALLBACK === "1";
425
+ }
426
+
427
+ function warnInlineReplayFallback(): void {
428
+ if (inlineReplayFallbackWarned) return;
429
+ inlineReplayFallbackWarned = true;
430
+ console.warn(
431
+ "[session-replay] Private blob storage is not configured; storing capped replay chunks inline in SQL. This is intended only for local/dev use.",
432
+ );
433
+ }
434
+
435
+ async function storeReplayChunkBlob(
436
+ chunk: NormalizedSessionReplayChunk,
437
+ options: {
438
+ publicKeyId: string;
439
+ recordingId: string;
440
+ ownerEmail: string;
441
+ orgId: string | null;
442
+ },
443
+ ): Promise<NormalizedSessionReplayChunk> {
444
+ if (chunk.storageKind === "blob" || !chunk.inlineData) return chunk;
445
+
446
+ const gzipBytes = gzipSync(Buffer.from(chunk.inlineData, "utf8"));
447
+ const handle = await putPrivateBlob({
448
+ data: gzipBytes,
449
+ key: `analytics/session-replay/${options.publicKeyId}/${options.recordingId}/${chunk.seq}.json.gz`,
450
+ filename: `${options.recordingId}-${chunk.seq}.json.gz`,
451
+ mimeType: "application/json+gzip",
452
+ ownerEmail: options.ownerEmail,
453
+ metadata: {
454
+ recordingId: options.recordingId,
455
+ seq: chunk.seq,
456
+ checksum: chunk.checksum,
457
+ orgId: options.orgId,
458
+ },
459
+ });
460
+ if (!handle) {
461
+ if (!productionInlineFallbackAllowed()) {
462
+ throw replayError(
463
+ "Session replay blob storage is required in production. Configure a private blob provider or set ANALYTICS_SESSION_REPLAY_SQL_FALLBACK=1 for a capped temporary fallback.",
464
+ 503,
465
+ );
466
+ }
467
+ warnInlineReplayFallback();
468
+ return chunk;
469
+ }
470
+
471
+ return {
472
+ ...chunk,
473
+ storageKind: "blob",
474
+ storageRef: encodeReplayBlobRef(handle),
475
+ inlineData: null,
476
+ };
477
+ }
478
+
479
+ function normalizeReplayChunk(rawValue: unknown): NormalizedSessionReplayChunk {
480
+ const raw = replayRecord(rawValue);
481
+ const seq = replayInteger(raw.seq ?? raw.sequence ?? raw.index);
482
+ if (seq === null || seq < 0) {
483
+ throw replayError("Each replay chunk requires a non-negative seq", 400);
484
+ }
485
+
486
+ const { inlineData, eventCount: inlineEventCount } =
487
+ normalizeReplayInlineData(raw);
488
+ const storageRef = normalizeReplayBlobRef(raw.blobRef ?? raw.storageRef);
489
+ if (inlineData && storageRef) {
490
+ throw replayError(
491
+ "Replay chunks must use either inline data or a private blob ref, not both",
492
+ 400,
493
+ );
494
+ }
495
+ if (!inlineData && !storageRef) {
496
+ throw replayError(
497
+ "Each replay chunk requires inline events/data or a private blob ref",
498
+ 400,
499
+ );
500
+ }
501
+
502
+ const storageKind = storageRef ? "blob" : "inline";
503
+ const byteLength =
504
+ storageKind === "inline"
505
+ ? Buffer.byteLength(inlineData ?? "", "utf8")
506
+ : (replayInteger(raw.byteLength ?? raw.bytes) ?? 0);
507
+ const maxBytes =
508
+ storageKind === "inline"
509
+ ? MAX_INLINE_REPLAY_CHUNK_BYTES
510
+ : MAX_BLOB_REPLAY_CHUNK_BYTES;
511
+ if (byteLength <= 0 || byteLength > maxBytes) {
512
+ throw replayError(
513
+ `Replay ${storageKind} chunks must be between 1 and ${maxBytes} bytes`,
514
+ 413,
515
+ );
516
+ }
517
+
518
+ const eventCount =
519
+ replayInteger(raw.eventCount) ??
520
+ inlineEventCount ??
521
+ inferReplayEventCount(inlineData) ??
522
+ 0;
523
+ if (eventCount < 0 || eventCount > MAX_REPLAY_EVENTS_PER_CHUNK) {
524
+ throw replayError(
525
+ `Replay chunks may contain at most ${MAX_REPLAY_EVENTS_PER_CHUNK} events`,
526
+ 413,
527
+ );
528
+ }
529
+
530
+ const checksum =
531
+ replayString(raw.checksum) ??
532
+ (inlineData ? replaySha256(inlineData) : null);
533
+ if (!checksum || checksum.length > 128) {
534
+ throw replayError("Each replay chunk requires a valid checksum", 400);
535
+ }
536
+
537
+ return {
538
+ seq,
539
+ checksum,
540
+ byteLength,
541
+ eventCount,
542
+ startedAt: replayTimestamp(raw.startedAt ?? raw.startTime ?? raw.start),
543
+ endedAt: replayTimestamp(raw.endedAt ?? raw.endTime ?? raw.end),
544
+ storageKind,
545
+ storageRef,
546
+ inlineData,
547
+ };
548
+ }
549
+
550
+ function extractReplayChunks(body: Record<string, unknown>): unknown[] {
551
+ if (Array.isArray(body.chunks)) return body.chunks;
552
+ if (body.chunk !== undefined) return [body.chunk];
553
+ if (
554
+ body.seq !== undefined ||
555
+ body.events !== undefined ||
556
+ body.data !== undefined
557
+ ) {
558
+ return [body];
559
+ }
560
+ return [];
561
+ }
562
+
563
+ function numberFrom(...values: unknown[]): number | null {
564
+ for (const value of values) {
565
+ const parsed = replayInteger(value);
566
+ if (parsed !== null && parsed >= 0) return parsed;
567
+ }
568
+ return null;
569
+ }
570
+
571
+ function inlineEventsForSignals(
572
+ chunks: NormalizedSessionReplayChunk[],
573
+ ): unknown[] {
574
+ const events: unknown[] = [];
575
+ for (const chunk of chunks) {
576
+ if (!chunk.inlineData) continue;
577
+ events.push(...parseInlineReplayEvents(chunk.inlineData));
578
+ if (events.length >= 5_000) return events.slice(0, 5_000);
579
+ }
580
+ return events;
581
+ }
582
+
583
+ function deriveReplaySignals({
584
+ body,
585
+ metadata,
586
+ chunks,
587
+ url,
588
+ }: {
589
+ body: Record<string, unknown>;
590
+ metadata: Record<string, unknown>;
591
+ chunks: NormalizedSessionReplayChunk[];
592
+ url: string | null;
593
+ }): {
594
+ pageCount: number;
595
+ errorCount: number;
596
+ rageClickCount: number;
597
+ privacyMode: string;
598
+ } {
599
+ const events = inlineEventsForSignals(chunks);
600
+ const pages = new Set<string>();
601
+ if (url) pages.add(url);
602
+ let detectedErrors = 0;
603
+
604
+ for (const event of events) {
605
+ const record = replayRecord(event);
606
+ const data = replayRecord(record.data);
607
+ const href = replayString(data.href ?? data.url);
608
+ if (href) pages.add(href);
609
+ const source = `${replayString(record.type) ?? ""} ${
610
+ replayString(data.type) ?? ""
611
+ } ${replayString(data.message) ?? ""}`.toLowerCase();
612
+ if (
613
+ source.includes("error") ||
614
+ source.includes("exception") ||
615
+ source.includes("unhandledrejection")
616
+ ) {
617
+ detectedErrors += 1;
618
+ }
619
+ }
620
+
621
+ return {
622
+ pageCount:
623
+ numberFrom(body.pageCount, body.page_count, metadata.pageCount) ??
624
+ pages.size,
625
+ errorCount:
626
+ numberFrom(body.errorCount, body.error_count, metadata.errorCount) ??
627
+ detectedErrors,
628
+ rageClickCount:
629
+ numberFrom(
630
+ body.rageClickCount,
631
+ body.rage_click_count,
632
+ body.rageClicks,
633
+ metadata.rageClickCount,
634
+ metadata.rageClicks,
635
+ ) ?? 0,
636
+ privacyMode:
637
+ replayString(body.privacyMode) ||
638
+ replayString(body.privacy_mode) ||
639
+ replayString(metadata.privacyMode) ||
640
+ "unknown",
641
+ };
642
+ }
643
+
644
+ export function parseSessionReplayIngestPayload(
645
+ raw: unknown,
646
+ ): ParsedSessionReplayIngest {
647
+ const body =
648
+ typeof raw === "string" && raw.trim() ? JSON.parse(raw) : replayRecord(raw);
649
+ const publicKey =
650
+ replayString(body.publicKey) ||
651
+ replayString(body.writeKey) ||
652
+ replayString(body.apiKey);
653
+ if (!publicKey) throw replayError("Missing publicKey", 400);
654
+
655
+ const rawChunks = extractReplayChunks(body);
656
+ if (!rawChunks.length) throw replayError("No replay chunks provided", 400);
657
+ if (rawChunks.length > MAX_REPLAY_CHUNKS_PER_REQUEST) {
658
+ throw replayError(
659
+ `At most ${MAX_REPLAY_CHUNKS_PER_REQUEST} replay chunks are accepted per request`,
660
+ 413,
661
+ );
662
+ }
663
+ const chunks = rawChunks.map(normalizeReplayChunk);
664
+
665
+ const sessionId =
666
+ replayString(body.sessionId) ||
667
+ replayString(body.session_id) ||
668
+ replayString(replayRecord(body.session).id);
669
+ if (!sessionId) throw replayError("Replay payload requires sessionId", 400);
670
+
671
+ const clientRecordingId =
672
+ replayString(body.recordingId) ||
673
+ replayString(body.recording_id) ||
674
+ replayString(body.replayId) ||
675
+ sessionId;
676
+ const metadata = replayRecord(body.metadata);
677
+ assertReplayMetadataCap(metadata);
678
+
679
+ const directApp = replayString(body.app);
680
+ const directTemplate = replayString(body.template);
681
+ const properties: Record<string, unknown> = {
682
+ ...replayRecord(body.properties),
683
+ ...(directApp ? { app: directApp } : {}),
684
+ ...(directTemplate ? { template: directTemplate } : {}),
685
+ };
686
+ const context: Record<string, unknown> = replayRecord(body.context);
687
+ const url =
688
+ replayString(body.url) ||
689
+ replayString(properties.url) ||
690
+ replayString(context.url);
691
+ const parts = normalizeReplayUrl(url);
692
+ const signals = deriveReplaySignals({
693
+ body,
694
+ metadata,
695
+ chunks,
696
+ url: parts.url,
697
+ });
698
+ const hostname =
699
+ parts.hostname ||
700
+ replayString(body.hostname) ||
701
+ replayString(properties.hostname) ||
702
+ replayString(context.hostname);
703
+ const { app, template } = resolveAnalyticsEventDimensions({
704
+ properties,
705
+ context,
706
+ hostname,
707
+ });
708
+ const startedAt =
709
+ replayTimestamp(body.startedAt ?? body.startTime ?? body.timestamp) ||
710
+ replayMinIso(chunks.map((chunk) => chunk.startedAt)) ||
711
+ replayNowIso();
712
+ const endedAt =
713
+ replayTimestamp(body.endedAt ?? body.endTime) ||
714
+ replayMaxIso(chunks.map((chunk) => chunk.endedAt));
715
+ const computedDuration = endedAt
716
+ ? Date.parse(endedAt) - Date.parse(startedAt)
717
+ : null;
718
+ const durationMs =
719
+ replayInteger(body.durationMs ?? body.duration_ms) ?? computedDuration;
720
+ const status =
721
+ body.status === "completed" || body.completed === true || endedAt
722
+ ? "completed"
723
+ : "active";
724
+ const userId = replayString(body.userId ?? body.user_id);
725
+ const anonymousId = replayString(body.anonymousId ?? body.anonymous_id);
726
+
727
+ return {
728
+ publicKey,
729
+ clientRecordingId,
730
+ sessionId,
731
+ userId,
732
+ anonymousId,
733
+ userKey: userId || anonymousId,
734
+ startedAt,
735
+ endedAt,
736
+ durationMs:
737
+ typeof durationMs === "number" && Number.isFinite(durationMs)
738
+ ? Math.max(0, durationMs)
739
+ : null,
740
+ url: parts.url,
741
+ path:
742
+ parts.path || replayString(body.path) || replayString(properties.path),
743
+ hostname,
744
+ referrer:
745
+ replayString(body.referrer) ||
746
+ replayString(properties.referrer) ||
747
+ replayString(context.referrer),
748
+ app,
749
+ template,
750
+ pageCount: signals.pageCount,
751
+ errorCount: signals.errorCount,
752
+ rageClickCount: signals.rageClickCount,
753
+ privacyMode: signals.privacyMode,
754
+ status,
755
+ metadata,
756
+ chunks,
757
+ };
758
+ }
759
+
760
+ export interface SessionReplayIngestContext {
761
+ origin?: string | null;
762
+ requestBytes?: number | null;
763
+ now?: Date;
764
+ }
765
+
766
+ export async function assertReplayKeyBudget(
767
+ key: {
768
+ id: string;
769
+ replayAllowedOrigins?: string | null;
770
+ replayMaxBytesPerDay?: number | null;
771
+ replayMaxRequestsPerMinute?: number | null;
772
+ },
773
+ context: SessionReplayIngestContext,
774
+ ): Promise<void> {
775
+ const origin = normalizeReplayOrigin(context.origin);
776
+ const allowedOrigins = parseAllowedReplayOrigins(key.replayAllowedOrigins);
777
+ if (allowedOrigins.length > 0 && !origin) {
778
+ throw replayError(
779
+ "Origin is required for replay ingestion with this analytics public key",
780
+ 403,
781
+ );
782
+ }
783
+ if (allowedOrigins.length > 0 && !allowedOrigins.includes(origin ?? "")) {
784
+ throw replayError(
785
+ "Origin is not allowed for this analytics public key",
786
+ 403,
787
+ );
788
+ }
789
+
790
+ const requestBytes = Math.max(0, context.requestBytes ?? 0);
791
+ const maxBytesPerDay = positiveReplayLimit(
792
+ key.replayMaxBytesPerDay,
793
+ DEFAULT_REPLAY_MAX_BYTES_PER_DAY,
794
+ );
795
+ if (requestBytes > maxBytesPerDay) {
796
+ throw replayError(
797
+ "Replay ingest request exceeds this key's byte limit",
798
+ 413,
799
+ );
800
+ }
801
+
802
+ const maxRequestsPerMinute = positiveReplayLimit(
803
+ key.replayMaxRequestsPerMinute,
804
+ DEFAULT_REPLAY_MAX_REQUESTS_PER_MINUTE,
805
+ );
806
+ const now = context.now ?? new Date();
807
+ const sinceDay = isoBefore(now, 24 * 60 * 60_000);
808
+ const sinceMinute = isoBefore(now, 60_000);
809
+ const db = getDb() as any;
810
+ // guard:allow-unscoped — ingest quotas are scoped by the resolved analytics public key and use append-only ingest usage rows.
811
+ const [dailyUsage] = await db
812
+ .select({
813
+ bytes: sql<number>`COALESCE(SUM(${schema.sessionReplayIngests.byteLength}), 0)`,
814
+ })
815
+ .from(schema.sessionReplayIngests)
816
+ .where(
817
+ and(
818
+ eq(schema.sessionReplayIngests.publicKeyId, key.id),
819
+ gte(schema.sessionReplayIngests.createdAt, sinceDay),
820
+ ),
821
+ );
822
+
823
+ const bytesToday = Number(dailyUsage?.bytes ?? 0);
824
+ if (bytesToday + requestBytes > maxBytesPerDay) {
825
+ throw replayError(
826
+ "Replay ingest byte quota exceeded for this public key",
827
+ 429,
828
+ );
829
+ }
830
+
831
+ // guard:allow-unscoped — ingest quotas are scoped by the resolved analytics public key and use append-only ingest usage rows.
832
+ const [minuteUsage] = await db
833
+ .select({
834
+ requests: sql<number>`COUNT(*)`,
835
+ })
836
+ .from(schema.sessionReplayIngests)
837
+ .where(
838
+ and(
839
+ eq(schema.sessionReplayIngests.publicKeyId, key.id),
840
+ gte(schema.sessionReplayIngests.createdAt, sinceMinute),
841
+ ),
842
+ );
843
+
844
+ const recentRequests = Number(minuteUsage?.requests ?? 0);
845
+ if (recentRequests >= maxRequestsPerMinute) {
846
+ throw replayError(
847
+ "Replay ingest rate limit exceeded for this public key",
848
+ 429,
849
+ );
850
+ }
851
+ }
852
+
853
+ async function resolveReplayPublicKey(
854
+ publicKey: string,
855
+ context: SessionReplayIngestContext = {},
856
+ ): Promise<{
857
+ id: string;
858
+ ownerEmail: string;
859
+ orgId: string | null;
860
+ }> {
861
+ const db = getDb() as any;
862
+ // guard:allow-unscoped -- public replay ingestion must resolve the owning tenant from the submitted write key before it can scope inserts.
863
+ const [key] = await db
864
+ .select()
865
+ .from(schema.analyticsPublicKeys)
866
+ .where(
867
+ and(
868
+ eq(schema.analyticsPublicKeys.publicKey, publicKey),
869
+ isNull(schema.analyticsPublicKeys.revokedAt),
870
+ ),
871
+ )
872
+ .limit(1);
873
+ if (!key) throw replayError("Invalid analytics public key", 401);
874
+ await assertReplayKeyBudget(key, context);
875
+ return {
876
+ id: key.id,
877
+ ownerEmail: key.ownerEmail,
878
+ orgId: key.orgId ?? null,
879
+ };
880
+ }
881
+
882
+ function parseRecordingMetadata(row: any): Record<string, unknown> {
883
+ try {
884
+ return replayRecord(JSON.parse(row.metadata ?? "{}"));
885
+ } catch {
886
+ return {};
887
+ }
888
+ }
889
+
890
+ function rowToSessionRecordingSummary(
891
+ row: any,
892
+ role?: SessionReplayAccessRole,
893
+ ): SessionRecordingSummary {
894
+ return {
895
+ id: row.id,
896
+ clientRecordingId: row.clientRecordingId,
897
+ sessionId: row.sessionId,
898
+ userId: row.userId ?? null,
899
+ anonymousId: row.anonymousId ?? null,
900
+ userKey: row.userKey ?? null,
901
+ startedAt: row.startedAt,
902
+ endedAt: row.endedAt ?? null,
903
+ durationMs: row.durationMs ?? null,
904
+ chunkCount: row.chunkCount ?? 0,
905
+ eventCount: row.eventCount ?? 0,
906
+ totalBytes: row.totalBytes ?? 0,
907
+ pageCount: row.pageCount ?? 0,
908
+ errorCount: row.errorCount ?? 0,
909
+ rageClickCount: row.rageClickCount ?? 0,
910
+ privacyMode: row.privacyMode ?? "unknown",
911
+ firstUrl: row.firstUrl ?? null,
912
+ lastUrl: row.lastUrl ?? null,
913
+ path: row.path ?? null,
914
+ hostname: row.hostname ?? null,
915
+ referrer: row.referrer ?? null,
916
+ app: row.app ?? null,
917
+ template: row.template ?? null,
918
+ status: row.status === "completed" ? "completed" : "active",
919
+ metadata: parseRecordingMetadata(row),
920
+ ownerEmail: row.ownerEmail,
921
+ orgId: row.orgId ?? null,
922
+ visibility: row.visibility,
923
+ createdAt: row.createdAt,
924
+ updatedAt: row.updatedAt,
925
+ lastIngestedAt: row.lastIngestedAt ?? null,
926
+ ...(role
927
+ ? {
928
+ role,
929
+ canEdit: roleSatisfies(role, "editor"),
930
+ canManage: roleSatisfies(role, "admin"),
931
+ }
932
+ : {}),
933
+ };
934
+ }
935
+
936
+ function mergeReplayMetadata(
937
+ existing: Record<string, unknown>,
938
+ incoming: Record<string, unknown>,
939
+ ): Record<string, unknown> {
940
+ const merged = { ...existing, ...incoming };
941
+ assertReplayMetadataCap(merged);
942
+ return merged;
943
+ }
944
+
945
+ function replayRecordingChangeScope(row: {
946
+ ownerEmail: string;
947
+ orgId: string | null;
948
+ visibility: string;
949
+ }): { owner?: string; orgId?: string } {
950
+ if (row.visibility === "org" && row.orgId) return { orgId: row.orgId };
951
+ return { owner: row.ownerEmail };
952
+ }
953
+
954
+ function escapeSqlLike(value: string): string {
955
+ return value.replace(/[\\%_]/g, (match) => `\\${match}`);
956
+ }
957
+
958
+ function replayTextContains(column: unknown, query: string) {
959
+ return sql`lower(coalesce(${column}, '')) like ${`%${escapeSqlLike(query.toLowerCase())}%`} escape '\\'`;
960
+ }
961
+
962
+ function replayListSearchCondition(query: string | undefined) {
963
+ const q = query?.trim();
964
+ if (!q) return null;
965
+ return or(
966
+ replayTextContains(schema.sessionRecordings.id, q),
967
+ replayTextContains(schema.sessionRecordings.sessionId, q),
968
+ replayTextContains(schema.sessionRecordings.clientRecordingId, q),
969
+ replayTextContains(schema.sessionRecordings.userId, q),
970
+ replayTextContains(schema.sessionRecordings.userKey, q),
971
+ replayTextContains(schema.sessionRecordings.anonymousId, q),
972
+ replayTextContains(schema.sessionRecordings.app, q),
973
+ replayTextContains(schema.sessionRecordings.template, q),
974
+ replayTextContains(schema.sessionRecordings.path, q),
975
+ replayTextContains(schema.sessionRecordings.firstUrl, q),
976
+ replayTextContains(schema.sessionRecordings.lastUrl, q),
977
+ replayTextContains(schema.sessionRecordings.hostname, q),
978
+ );
979
+ }
980
+
981
+ export async function recordSessionReplayChunks(
982
+ input: ParsedSessionReplayIngest,
983
+ context: SessionReplayIngestContext = {},
984
+ ): Promise<{
985
+ recordingId: string;
986
+ sessionId: string;
987
+ acceptedChunks: number;
988
+ duplicateChunks: number;
989
+ chunkCount: number;
990
+ eventCount: number;
991
+ totalBytes: number;
992
+ }> {
993
+ const key = await resolveReplayPublicKey(input.publicKey, context);
994
+ const db = getDb() as any;
995
+ const ingestedAt = replayNowIso();
996
+
997
+ let [recording] = await db
998
+ .select()
999
+ .from(schema.sessionRecordings)
1000
+ .where(
1001
+ and(
1002
+ eq(schema.sessionRecordings.publicKeyId, key.id),
1003
+ eq(schema.sessionRecordings.clientRecordingId, input.clientRecordingId),
1004
+ ),
1005
+ )
1006
+ .limit(1);
1007
+
1008
+ if (!recording) {
1009
+ await db
1010
+ .insert(schema.sessionRecordings)
1011
+ .values({
1012
+ id: replayId("sr"),
1013
+ publicKeyId: key.id,
1014
+ clientRecordingId: input.clientRecordingId,
1015
+ sessionId: input.sessionId,
1016
+ userId: input.userId,
1017
+ anonymousId: input.anonymousId,
1018
+ userKey: input.userKey,
1019
+ startedAt: input.startedAt,
1020
+ endedAt: input.endedAt,
1021
+ durationMs: input.durationMs,
1022
+ pageCount: input.pageCount,
1023
+ errorCount: input.errorCount,
1024
+ rageClickCount: input.rageClickCount,
1025
+ privacyMode: input.privacyMode,
1026
+ firstUrl: input.url,
1027
+ lastUrl: input.url,
1028
+ path: input.path,
1029
+ hostname: input.hostname,
1030
+ referrer: input.referrer,
1031
+ app: input.app,
1032
+ template: input.template,
1033
+ status: input.status,
1034
+ metadata: JSON.stringify(input.metadata),
1035
+ lastIngestedAt: ingestedAt,
1036
+ ownerEmail: key.ownerEmail,
1037
+ orgId: key.orgId,
1038
+ visibility: "private",
1039
+ })
1040
+ .onConflictDoNothing();
1041
+
1042
+ [recording] = await db
1043
+ .select()
1044
+ .from(schema.sessionRecordings)
1045
+ .where(
1046
+ and(
1047
+ eq(schema.sessionRecordings.publicKeyId, key.id),
1048
+ eq(
1049
+ schema.sessionRecordings.clientRecordingId,
1050
+ input.clientRecordingId,
1051
+ ),
1052
+ ),
1053
+ )
1054
+ .limit(1);
1055
+ }
1056
+
1057
+ if (!recording) throw replayError("Unable to create replay recording", 500);
1058
+ if (
1059
+ recording.ownerEmail !== key.ownerEmail ||
1060
+ (recording.orgId ?? null) !== key.orgId
1061
+ ) {
1062
+ throw replayError("Replay recording belongs to a different scope", 409);
1063
+ }
1064
+
1065
+ const existingChunks = await db
1066
+ .select()
1067
+ .from(schema.sessionReplayChunks)
1068
+ .where(eq(schema.sessionReplayChunks.recordingId, recording.id));
1069
+ const existingBySeq = new Map<number, any>(
1070
+ existingChunks.map((chunk: any) => [chunk.seq, chunk]),
1071
+ );
1072
+ const rowsToInsert: any[] = [];
1073
+ let duplicateChunks = 0;
1074
+
1075
+ for (const rawChunk of input.chunks) {
1076
+ const chunk = await storeReplayChunkBlob(rawChunk, {
1077
+ publicKeyId: key.id,
1078
+ recordingId: recording.id,
1079
+ ownerEmail: key.ownerEmail,
1080
+ orgId: key.orgId,
1081
+ });
1082
+ const existing = existingBySeq.get(chunk.seq);
1083
+ if (existing) {
1084
+ if (existing.checksum !== chunk.checksum) {
1085
+ throw replayError(
1086
+ `Replay chunk ${chunk.seq} was already recorded with a different checksum`,
1087
+ 409,
1088
+ );
1089
+ }
1090
+ duplicateChunks += 1;
1091
+ continue;
1092
+ }
1093
+ if (
1094
+ existingChunks.length + rowsToInsert.length >=
1095
+ MAX_REPLAY_CHUNKS_PER_RECORDING
1096
+ ) {
1097
+ throw replayError(
1098
+ `Session recordings may contain at most ${MAX_REPLAY_CHUNKS_PER_RECORDING} chunks`,
1099
+ 413,
1100
+ );
1101
+ }
1102
+ rowsToInsert.push({
1103
+ id: replayId("src"),
1104
+ recordingId: recording.id,
1105
+ seq: chunk.seq,
1106
+ checksum: chunk.checksum,
1107
+ byteLength: chunk.byteLength,
1108
+ eventCount: chunk.eventCount,
1109
+ startedAt: chunk.startedAt,
1110
+ endedAt: chunk.endedAt,
1111
+ storageKind: chunk.storageKind,
1112
+ storageRef: chunk.storageRef,
1113
+ inlineData: chunk.inlineData,
1114
+ ownerEmail: key.ownerEmail,
1115
+ orgId: key.orgId,
1116
+ });
1117
+ }
1118
+
1119
+ if (rowsToInsert.length) {
1120
+ await db.insert(schema.sessionReplayChunks).values(rowsToInsert);
1121
+ }
1122
+
1123
+ await db.insert(schema.sessionReplayIngests).values({
1124
+ id: replayId("sri"),
1125
+ publicKeyId: key.id,
1126
+ recordingId: recording.id,
1127
+ byteLength: replayIngestByteLength(input, context),
1128
+ createdAt: ingestedAt,
1129
+ ownerEmail: key.ownerEmail,
1130
+ orgId: key.orgId,
1131
+ });
1132
+
1133
+ const allChunks = [...existingChunks, ...rowsToInsert];
1134
+ const chunkCount = allChunks.length;
1135
+ const eventCount = allChunks.reduce(
1136
+ (sum, chunk: any) => sum + Number(chunk.eventCount ?? 0),
1137
+ 0,
1138
+ );
1139
+ const totalBytes = allChunks.reduce(
1140
+ (sum, chunk: any) => sum + Number(chunk.byteLength ?? 0),
1141
+ 0,
1142
+ );
1143
+ const startedAt =
1144
+ replayMinIso([
1145
+ recording.startedAt,
1146
+ input.startedAt,
1147
+ ...allChunks.map((chunk: any) => chunk.startedAt),
1148
+ ]) ?? input.startedAt;
1149
+ const endedAt =
1150
+ replayMaxIso([
1151
+ recording.endedAt,
1152
+ input.endedAt,
1153
+ ...allChunks.map((chunk: any) => chunk.endedAt),
1154
+ ]) ?? null;
1155
+ const durationMs =
1156
+ input.durationMs ??
1157
+ (endedAt
1158
+ ? Math.max(0, Date.parse(endedAt) - Date.parse(startedAt))
1159
+ : (recording.durationMs ?? null));
1160
+ const metadata = mergeReplayMetadata(
1161
+ parseRecordingMetadata(recording),
1162
+ input.metadata,
1163
+ );
1164
+
1165
+ await db
1166
+ .update(schema.sessionRecordings)
1167
+ .set({
1168
+ sessionId: input.sessionId,
1169
+ userId: input.userId ?? recording.userId ?? null,
1170
+ anonymousId: input.anonymousId ?? recording.anonymousId ?? null,
1171
+ userKey: input.userKey ?? recording.userKey ?? null,
1172
+ startedAt,
1173
+ endedAt,
1174
+ durationMs,
1175
+ chunkCount,
1176
+ eventCount,
1177
+ totalBytes,
1178
+ pageCount: Math.max(Number(recording.pageCount ?? 0), input.pageCount),
1179
+ errorCount: Math.max(Number(recording.errorCount ?? 0), input.errorCount),
1180
+ rageClickCount: Math.max(
1181
+ Number(recording.rageClickCount ?? 0),
1182
+ input.rageClickCount,
1183
+ ),
1184
+ privacyMode:
1185
+ input.privacyMode !== "unknown"
1186
+ ? input.privacyMode
1187
+ : (recording.privacyMode ?? "unknown"),
1188
+ firstUrl: recording.firstUrl ?? input.url,
1189
+ lastUrl: input.url ?? recording.lastUrl ?? null,
1190
+ path: input.path ?? recording.path ?? null,
1191
+ hostname: input.hostname ?? recording.hostname ?? null,
1192
+ referrer: input.referrer ?? recording.referrer ?? null,
1193
+ app: input.app ?? recording.app ?? null,
1194
+ template: input.template ?? recording.template ?? null,
1195
+ status:
1196
+ input.status === "completed" || recording.status === "completed"
1197
+ ? "completed"
1198
+ : "active",
1199
+ metadata: JSON.stringify(metadata),
1200
+ updatedAt: ingestedAt,
1201
+ lastIngestedAt: ingestedAt,
1202
+ })
1203
+ .where(eq(schema.sessionRecordings.id, recording.id));
1204
+
1205
+ await db
1206
+ .update(schema.analyticsPublicKeys)
1207
+ .set({ lastUsedAt: ingestedAt })
1208
+ .where(eq(schema.analyticsPublicKeys.id, key.id));
1209
+
1210
+ recordChange({
1211
+ source: "session-recordings",
1212
+ type: "change",
1213
+ key: recording.id,
1214
+ ...replayRecordingChangeScope(recording),
1215
+ });
1216
+
1217
+ return {
1218
+ recordingId: recording.id,
1219
+ sessionId: input.sessionId,
1220
+ acceptedChunks: rowsToInsert.length,
1221
+ duplicateChunks,
1222
+ chunkCount,
1223
+ eventCount,
1224
+ totalBytes,
1225
+ };
1226
+ }
1227
+
1228
+ export async function listSessionRecordings(
1229
+ scope: SessionReplayScope,
1230
+ filters: SessionReplayListFilters = {},
1231
+ ): Promise<SessionRecordingSummary[]> {
1232
+ const db = getDb() as any;
1233
+ const limit = Math.min(
1234
+ MAX_SESSION_RECORDINGS_LIMIT,
1235
+ Math.max(1, filters.limit ?? DEFAULT_SESSION_RECORDINGS_LIMIT),
1236
+ );
1237
+ const conditions: any[] = [
1238
+ accessFilter(schema.sessionRecordings, schema.sessionRecordingShares, {
1239
+ userEmail: scope.userEmail,
1240
+ orgId: scope.orgId ?? undefined,
1241
+ }),
1242
+ ];
1243
+ if (filters.app)
1244
+ conditions.push(eq(schema.sessionRecordings.app, filters.app));
1245
+ if (filters.template) {
1246
+ conditions.push(eq(schema.sessionRecordings.template, filters.template));
1247
+ }
1248
+ if (filters.sessionId) {
1249
+ conditions.push(eq(schema.sessionRecordings.sessionId, filters.sessionId));
1250
+ }
1251
+ if (filters.userId) {
1252
+ conditions.push(eq(schema.sessionRecordings.userId, filters.userId));
1253
+ }
1254
+ if (filters.anonymousId) {
1255
+ conditions.push(
1256
+ eq(schema.sessionRecordings.anonymousId, filters.anonymousId),
1257
+ );
1258
+ }
1259
+ if (filters.path) {
1260
+ conditions.push(eq(schema.sessionRecordings.path, filters.path));
1261
+ }
1262
+ if (filters.from) {
1263
+ conditions.push(gte(schema.sessionRecordings.startedAt, filters.from));
1264
+ }
1265
+ if (filters.to) {
1266
+ conditions.push(lte(schema.sessionRecordings.startedAt, filters.to));
1267
+ }
1268
+ if (filters.minDurationMs !== undefined) {
1269
+ conditions.push(
1270
+ gte(schema.sessionRecordings.durationMs, filters.minDurationMs),
1271
+ );
1272
+ }
1273
+ if (filters.hasErrors) {
1274
+ conditions.push(gte(schema.sessionRecordings.errorCount, 1));
1275
+ }
1276
+ if (filters.hasRageClicks) {
1277
+ conditions.push(gte(schema.sessionRecordings.rageClickCount, 1));
1278
+ }
1279
+ if (filters.status) {
1280
+ conditions.push(eq(schema.sessionRecordings.status, filters.status));
1281
+ }
1282
+ const search = replayListSearchCondition(filters.query);
1283
+ if (search) conditions.push(search);
1284
+
1285
+ const rows = await db
1286
+ .select()
1287
+ .from(schema.sessionRecordings)
1288
+ .where(and(...conditions))
1289
+ .orderBy(desc(schema.sessionRecordings.startedAt))
1290
+ .limit(limit);
1291
+ return rows.map((row: any) => rowToSessionRecordingSummary(row));
1292
+ }
1293
+
1294
+ export async function getSessionReplaySummary(
1295
+ recordingId: string,
1296
+ scope: SessionReplayScope,
1297
+ ): Promise<SessionRecordingSummary> {
1298
+ const access = await resolveAccess("session-recording", recordingId, {
1299
+ userEmail: scope.userEmail,
1300
+ orgId: scope.orgId ?? undefined,
1301
+ });
1302
+ if (!access) throw replayError("Session recording not found", 404);
1303
+ return rowToSessionRecordingSummary(access.resource, access.role);
1304
+ }
1305
+
1306
+ function parseInlineReplayEvents(inlineData: string): unknown[] {
1307
+ try {
1308
+ const parsed = JSON.parse(inlineData);
1309
+ if (Array.isArray(parsed)) return parsed;
1310
+ if (Array.isArray(parsed?.events)) return parsed.events;
1311
+ return [parsed];
1312
+ } catch {
1313
+ return [{ data: inlineData }];
1314
+ }
1315
+ }
1316
+
1317
+ async function readStoredReplayEvents(row: any): Promise<unknown[]> {
1318
+ if (row.storageKind === "inline" && row.inlineData) {
1319
+ return parseInlineReplayEvents(row.inlineData);
1320
+ }
1321
+ if (row.storageKind !== "blob" || !row.storageRef) return [];
1322
+ const ref = decodeReplayBlobRef(row.storageRef);
1323
+ if (!ref) return [];
1324
+ const blob = await readPrivateBlob(ref.handle);
1325
+ const json = gunzipSync(Buffer.from(blob.data)).toString("utf8");
1326
+ return parseInlineReplayEvents(json);
1327
+ }
1328
+
1329
+ export async function getSessionReplayManifest(
1330
+ recordingId: string,
1331
+ scope: SessionReplayScope,
1332
+ ): Promise<{
1333
+ recording: SessionRecordingSummary;
1334
+ chunks: Array<{
1335
+ seq: number;
1336
+ checksum: string;
1337
+ byteLength: number;
1338
+ eventCount: number;
1339
+ startedAt: string | null;
1340
+ endedAt: string | null;
1341
+ bytesPath: string;
1342
+ }>;
1343
+ }> {
1344
+ const recording = await getSessionReplaySummary(recordingId, scope);
1345
+ const db = getDb() as any;
1346
+ // guard:allow-unscoped -- chunk rows are loaded only after resolveAccess("session-recording", recordingId) verifies viewer access; chunks are not directly shareable resources.
1347
+ const rows = await db
1348
+ .select()
1349
+ .from(schema.sessionReplayChunks)
1350
+ .where(eq(schema.sessionReplayChunks.recordingId, recording.id))
1351
+ .orderBy(asc(schema.sessionReplayChunks.seq));
1352
+
1353
+ return {
1354
+ recording,
1355
+ chunks: rows.map((row: any) => ({
1356
+ seq: row.seq,
1357
+ checksum: row.checksum,
1358
+ byteLength: row.byteLength,
1359
+ eventCount: row.eventCount,
1360
+ startedAt: row.startedAt ?? null,
1361
+ endedAt: row.endedAt ?? null,
1362
+ bytesPath: `/api/session-replay/recordings/${encodeURIComponent(
1363
+ recording.id,
1364
+ )}/chunks/${encodeURIComponent(String(row.seq))}`,
1365
+ })),
1366
+ };
1367
+ }
1368
+
1369
+ export async function readSessionReplayChunkBytes(
1370
+ recordingId: string,
1371
+ seq: number,
1372
+ scope: SessionReplayScope,
1373
+ ): Promise<{
1374
+ recording: SessionRecordingSummary;
1375
+ seq: number;
1376
+ checksum: string;
1377
+ data: Buffer;
1378
+ }> {
1379
+ const recording = await getSessionReplaySummary(recordingId, scope);
1380
+ const db = getDb() as any;
1381
+ // guard:allow-unscoped -- chunk rows are loaded only after resolveAccess("session-recording", recordingId) verifies viewer access; chunks are not directly shareable resources.
1382
+ const [row] = await db
1383
+ .select()
1384
+ .from(schema.sessionReplayChunks)
1385
+ .where(
1386
+ and(
1387
+ eq(schema.sessionReplayChunks.recordingId, recording.id),
1388
+ eq(schema.sessionReplayChunks.seq, seq),
1389
+ ),
1390
+ )
1391
+ .limit(1);
1392
+ if (!row) throw replayError("Session replay chunk not found", 404);
1393
+
1394
+ if (row.storageKind === "blob" && row.storageRef) {
1395
+ const ref = decodeReplayBlobRef(row.storageRef);
1396
+ if (!ref)
1397
+ throw replayError("Session replay blob reference is invalid", 500);
1398
+ const blob = await readPrivateBlob(ref.handle);
1399
+ return {
1400
+ recording,
1401
+ seq: row.seq,
1402
+ checksum: row.checksum,
1403
+ data: Buffer.from(blob.data),
1404
+ };
1405
+ }
1406
+ if (!row.inlineData)
1407
+ throw replayError("Session replay chunk is unavailable", 404);
1408
+ return {
1409
+ recording,
1410
+ seq: row.seq,
1411
+ checksum: row.checksum,
1412
+ data: gzipSync(Buffer.from(row.inlineData, "utf8")),
1413
+ };
1414
+ }
1415
+
1416
+ export async function getSessionReplayEvents(
1417
+ recordingId: string,
1418
+ scope: SessionReplayScope,
1419
+ options: SessionReplayEventReadOptions = {},
1420
+ ): Promise<{
1421
+ recording: SessionRecordingSummary;
1422
+ chunks: Array<{
1423
+ seq: number;
1424
+ checksum: string;
1425
+ byteLength: number;
1426
+ eventCount: number;
1427
+ events: unknown[];
1428
+ unavailable?: boolean;
1429
+ }>;
1430
+ eventCount: number;
1431
+ truncated: boolean;
1432
+ unavailableChunks: number;
1433
+ }> {
1434
+ const recording = await getSessionReplaySummary(recordingId, scope);
1435
+ const maxEvents = Math.min(
1436
+ MAX_REPLAY_EVENTS_READ,
1437
+ Math.max(1, options.limit ?? MAX_REPLAY_EVENTS_READ),
1438
+ );
1439
+ const conditions: any[] = [
1440
+ eq(schema.sessionReplayChunks.recordingId, recording.id),
1441
+ ];
1442
+ if (options.startSeq !== undefined) {
1443
+ conditions.push(gte(schema.sessionReplayChunks.seq, options.startSeq));
1444
+ }
1445
+ if (options.endSeq !== undefined) {
1446
+ conditions.push(lte(schema.sessionReplayChunks.seq, options.endSeq));
1447
+ }
1448
+
1449
+ const db = getDb() as any;
1450
+ // guard:allow-unscoped -- chunk rows are loaded only after resolveAccess("session-recording", recordingId) verifies viewer access; chunks are not directly shareable resources.
1451
+ const rows = await db
1452
+ .select()
1453
+ .from(schema.sessionReplayChunks)
1454
+ .where(and(...conditions))
1455
+ .orderBy(asc(schema.sessionReplayChunks.seq));
1456
+
1457
+ const chunks: Array<{
1458
+ seq: number;
1459
+ checksum: string;
1460
+ byteLength: number;
1461
+ eventCount: number;
1462
+ events: unknown[];
1463
+ unavailable?: boolean;
1464
+ }> = [];
1465
+ let emittedEvents = 0;
1466
+ let emittedBytes = 0;
1467
+ let truncated = false;
1468
+ let unavailableChunks = 0;
1469
+
1470
+ for (const row of rows) {
1471
+ const events = await readStoredReplayEvents(row).catch(() => []);
1472
+ if (!events.length) {
1473
+ unavailableChunks += 1;
1474
+ chunks.push({
1475
+ seq: row.seq,
1476
+ checksum: row.checksum,
1477
+ byteLength: row.byteLength,
1478
+ eventCount: row.eventCount,
1479
+ events: [],
1480
+ unavailable: true,
1481
+ });
1482
+ continue;
1483
+ }
1484
+
1485
+ const emittedForChunk: unknown[] = [];
1486
+ for (const replayEvent of events) {
1487
+ const eventBytes = Buffer.byteLength(JSON.stringify(replayEvent), "utf8");
1488
+ if (
1489
+ emittedEvents >= maxEvents ||
1490
+ emittedBytes + eventBytes > MAX_REPLAY_EVENTS_RESPONSE_BYTES
1491
+ ) {
1492
+ truncated = true;
1493
+ break;
1494
+ }
1495
+ emittedForChunk.push(replayEvent);
1496
+ emittedEvents += 1;
1497
+ emittedBytes += eventBytes;
1498
+ }
1499
+ chunks.push({
1500
+ seq: row.seq,
1501
+ checksum: row.checksum,
1502
+ byteLength: row.byteLength,
1503
+ eventCount: row.eventCount,
1504
+ events: emittedForChunk,
1505
+ });
1506
+ if (truncated) break;
1507
+ }
1508
+
1509
+ return {
1510
+ recording,
1511
+ chunks,
1512
+ eventCount: emittedEvents,
1513
+ truncated,
1514
+ unavailableChunks,
1515
+ };
1516
+ }
1517
+
1518
+ export async function finalizeAbandonedSessionRecordings(
1519
+ now = new Date(),
1520
+ ): Promise<{ finalized: number }> {
1521
+ const cutoff = isoBefore(now, abandonedReplayMinutes() * 60_000);
1522
+ const db = getDb() as any;
1523
+ // guard:allow-unscoped — retention finalizes abandoned replay rows across owners and never returns row data to a caller.
1524
+ const rows = await db
1525
+ .select()
1526
+ .from(schema.sessionRecordings)
1527
+ .where(
1528
+ and(
1529
+ eq(schema.sessionRecordings.status, "active"),
1530
+ lt(schema.sessionRecordings.updatedAt, cutoff),
1531
+ ),
1532
+ )
1533
+ .limit(RETENTION_DELETE_BATCH_SIZE);
1534
+
1535
+ let finalized = 0;
1536
+ for (const row of rows) {
1537
+ const endedAt = row.lastIngestedAt ?? row.updatedAt ?? row.startedAt;
1538
+ const started = Date.parse(row.startedAt);
1539
+ const ended = Date.parse(endedAt);
1540
+ const durationMs =
1541
+ Number.isFinite(started) && Number.isFinite(ended)
1542
+ ? Math.max(0, ended - started)
1543
+ : (row.durationMs ?? null);
1544
+ await db
1545
+ .update(schema.sessionRecordings)
1546
+ .set({
1547
+ status: "completed",
1548
+ endedAt,
1549
+ durationMs,
1550
+ updatedAt: now.toISOString(),
1551
+ })
1552
+ .where(eq(schema.sessionRecordings.id, row.id));
1553
+ finalized++;
1554
+ }
1555
+
1556
+ return { finalized };
1557
+ }
1558
+
1559
+ async function deleteReplayChunkBlob(row: any): Promise<boolean> {
1560
+ if (row.storageKind !== "blob" || !row.storageRef) return true;
1561
+ const ref = decodeReplayBlobRef(row.storageRef);
1562
+ if (!ref) return false;
1563
+ const result = await deletePrivateBlob(ref.handle);
1564
+ return result.deleted === true;
1565
+ }
1566
+
1567
+ export async function expireOldSessionRecordings(
1568
+ now = new Date(),
1569
+ ): Promise<{ expired: number; chunks: number; blobDeleteFailures: number }> {
1570
+ const cutoff = isoBefore(now, replayRetentionDays() * 24 * 60 * 60_000);
1571
+ const db = getDb() as any;
1572
+ // guard:allow-unscoped — retention expiration intentionally sweeps old replay rows across owners.
1573
+ const recordings = await db
1574
+ .select({ id: schema.sessionRecordings.id })
1575
+ .from(schema.sessionRecordings)
1576
+ .where(lt(schema.sessionRecordings.startedAt, cutoff))
1577
+ .limit(RETENTION_DELETE_BATCH_SIZE);
1578
+
1579
+ let expired = 0;
1580
+ let chunks = 0;
1581
+ let blobDeleteFailures = 0;
1582
+ for (const recording of recordings) {
1583
+ const chunkRows = await db
1584
+ .select()
1585
+ .from(schema.sessionReplayChunks)
1586
+ .where(eq(schema.sessionReplayChunks.recordingId, recording.id));
1587
+
1588
+ let chunkDeleteFailed = false;
1589
+ for (const chunk of chunkRows) {
1590
+ try {
1591
+ const deleted = await deleteReplayChunkBlob(chunk);
1592
+ if (!deleted) {
1593
+ chunkDeleteFailed = true;
1594
+ blobDeleteFailures++;
1595
+ console.warn(
1596
+ "[session-replay] Private replay blob provider reported no deletion for chunk",
1597
+ chunk.id,
1598
+ );
1599
+ }
1600
+ } catch (err) {
1601
+ chunkDeleteFailed = true;
1602
+ blobDeleteFailures++;
1603
+ console.warn("[session-replay] Failed to delete replay blob:", err);
1604
+ }
1605
+ }
1606
+
1607
+ if (chunkDeleteFailed) continue;
1608
+
1609
+ await db
1610
+ .delete(schema.sessionReplayChunks)
1611
+ .where(eq(schema.sessionReplayChunks.recordingId, recording.id));
1612
+ await db
1613
+ .delete(schema.sessionReplayIngests)
1614
+ .where(eq(schema.sessionReplayIngests.recordingId, recording.id));
1615
+ await db
1616
+ .delete(schema.sessionRecordingShares)
1617
+ .where(eq(schema.sessionRecordingShares.resourceId, recording.id));
1618
+ await db
1619
+ .delete(schema.sessionRecordings)
1620
+ .where(eq(schema.sessionRecordings.id, recording.id));
1621
+
1622
+ expired++;
1623
+ chunks += chunkRows.length;
1624
+ }
1625
+
1626
+ return { expired, chunks, blobDeleteFailures };
1627
+ }
1628
+
1629
+ export async function runSessionReplayRetentionSweep(
1630
+ now = new Date(),
1631
+ ): Promise<{
1632
+ finalized: number;
1633
+ expired: number;
1634
+ chunks: number;
1635
+ blobDeleteFailures: number;
1636
+ }> {
1637
+ const finalized = await finalizeAbandonedSessionRecordings(now);
1638
+ const expired = await expireOldSessionRecordings(now);
1639
+ return {
1640
+ finalized: finalized.finalized,
1641
+ expired: expired.expired,
1642
+ chunks: expired.chunks,
1643
+ blobDeleteFailures: expired.blobDeleteFailures,
1644
+ };
1645
+ }