@stacksjs/ts-analytics 0.1.6

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 (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. package/src/version.ts +7 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * OAuth/SSO sign-in — Google + GitHub (#53).
3
+ *
4
+ * Standard authorization-code flow, linking to the existing USER# accounts by
5
+ * verified email (created on first login). Email/password stays primary.
6
+ *
7
+ * Configuration (per provider; the start endpoint 503s when unset):
8
+ * GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
9
+ * GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET
10
+ * The provider app's callback URL must be
11
+ * {APP_BASE_URL}/api/auth/oauth/{provider}/callback
12
+ *
13
+ * CSRF: each start mints a single-use state record (OAUTH#{state}, 10min TTL)
14
+ * that the callback consumes; a mismatched or replayed state is rejected.
15
+ */
16
+ import { dynamodb, TABLE_NAME, marshall, unmarshall } from '../lib/dynamodb'
17
+ import { jsonResponse, errorResponse } from '../utils/response'
18
+ import { getUserByEmail, createOAuthUser, createSession, sessionCookie, updateUserFields } from './auth'
19
+ import { acceptInvites } from '../lib/membership'
20
+ import { appBaseUrl } from '../lib/email'
21
+
22
+ export type OAuthProvider = 'google' | 'github'
23
+
24
+ interface ProviderConfig {
25
+ authorizeUrl: string
26
+ tokenUrl: string
27
+ scope: string
28
+ clientId: string
29
+ clientSecret: string
30
+ }
31
+
32
+ /** Provider endpoints + credentials; null when the env credentials are unset. */
33
+ export function providerConfig(provider: string): ProviderConfig | null {
34
+ if (provider === 'google') {
35
+ const clientId = process.env.GOOGLE_CLIENT_ID || ''
36
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET || ''
37
+ if (!clientId || !clientSecret)
38
+ return null
39
+ return {
40
+ authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
41
+ tokenUrl: 'https://oauth2.googleapis.com/token',
42
+ scope: 'openid email profile',
43
+ clientId,
44
+ clientSecret,
45
+ }
46
+ }
47
+ if (provider === 'github') {
48
+ const clientId = process.env.GITHUB_CLIENT_ID || ''
49
+ const clientSecret = process.env.GITHUB_CLIENT_SECRET || ''
50
+ if (!clientId || !clientSecret)
51
+ return null
52
+ return {
53
+ authorizeUrl: 'https://github.com/login/oauth/authorize',
54
+ tokenUrl: 'https://github.com/login/oauth/access_token',
55
+ scope: 'read:user user:email',
56
+ clientId,
57
+ clientSecret,
58
+ }
59
+ }
60
+ return null
61
+ }
62
+
63
+ /** The authorize redirect URL. Pure — unit tested. */
64
+ export function buildAuthorizeUrl(cfg: Pick<ProviderConfig, 'authorizeUrl' | 'clientId' | 'scope'>, redirectUri: string, state: string): string {
65
+ const params = new URLSearchParams({
66
+ client_id: cfg.clientId,
67
+ redirect_uri: redirectUri,
68
+ response_type: 'code',
69
+ scope: cfg.scope,
70
+ state,
71
+ })
72
+ return `${cfg.authorizeUrl}?${params}`
73
+ }
74
+
75
+ function redirectUriFor(provider: string): string {
76
+ return `${appBaseUrl()}/api/auth/oauth/${provider}/callback`
77
+ }
78
+
79
+ // ── Single-use CSRF state ───────────────────────────────────────────────────
80
+ async function createState(provider: string): Promise<string> {
81
+ const state = crypto.randomUUID()
82
+ await dynamodb.putItem({
83
+ TableName: TABLE_NAME,
84
+ Item: marshall({
85
+ pk: `OAUTH#${state}`,
86
+ sk: `OAUTH#${state}`,
87
+ provider,
88
+ ttl: Math.floor(Date.now() / 1000) + 600,
89
+ }),
90
+ })
91
+ return state
92
+ }
93
+
94
+ async function consumeState(state: string): Promise<string | null> {
95
+ const key = { pk: { S: `OAUTH#${state}` }, sk: { S: `OAUTH#${state}` } }
96
+ const r = await dynamodb.getItem({ TableName: TABLE_NAME, Key: key })
97
+ if (!r.Item)
98
+ return null
99
+ await dynamodb.deleteItem({ TableName: TABLE_NAME, Key: key })
100
+ const item = unmarshall(r.Item)
101
+ if (item.ttl && item.ttl < Math.floor(Date.now() / 1000))
102
+ return null
103
+ return item.provider || null
104
+ }
105
+
106
+ // ── Handlers ────────────────────────────────────────────────────────────────
107
+
108
+ /** GET /api/auth/oauth/{provider} — redirect to the provider's consent page. */
109
+ export async function handleOAuthStart(_request: Request, provider: string): Promise<Response> {
110
+ const cfg = providerConfig(provider)
111
+ if (!cfg) {
112
+ return jsonResponse({ error: `${provider} sign-in is not configured on this server` }, 503)
113
+ }
114
+ const state = await createState(provider)
115
+ const url = buildAuthorizeUrl(cfg, redirectUriFor(provider), state)
116
+ return new Response(null, { status: 302, headers: { Location: url } })
117
+ }
118
+
119
+ interface OAuthProfile {
120
+ email: string
121
+ emailVerified: boolean
122
+ name: string
123
+ providerId: string
124
+ }
125
+
126
+ async function exchangeCode(cfg: ProviderConfig, provider: string, code: string): Promise<string | null> {
127
+ const res = await fetch(cfg.tokenUrl, {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
130
+ body: new URLSearchParams({
131
+ client_id: cfg.clientId,
132
+ client_secret: cfg.clientSecret,
133
+ code,
134
+ grant_type: 'authorization_code',
135
+ redirect_uri: redirectUriFor(provider),
136
+ }),
137
+ })
138
+ if (!res.ok)
139
+ return null
140
+ const data = await res.json() as { access_token?: string }
141
+ return data.access_token || null
142
+ }
143
+
144
+ async function fetchProfile(provider: string, accessToken: string): Promise<OAuthProfile | null> {
145
+ const auth = { Authorization: `Bearer ${accessToken}` }
146
+ if (provider === 'google') {
147
+ const res = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { headers: auth })
148
+ if (!res.ok)
149
+ return null
150
+ const p = await res.json() as { sub?: string, email?: string, email_verified?: boolean, name?: string }
151
+ if (!p.email)
152
+ return null
153
+ return { email: p.email, emailVerified: !!p.email_verified, name: p.name || '', providerId: String(p.sub || '') }
154
+ }
155
+ if (provider === 'github') {
156
+ const res = await fetch('https://api.github.com/user', { headers: { ...auth, 'User-Agent': 'ts-analytics' } })
157
+ if (!res.ok)
158
+ return null
159
+ const p = await res.json() as { id?: number, name?: string, login?: string, email?: string | null }
160
+ let email = p.email || ''
161
+ let verified = false
162
+ const emailsRes = await fetch('https://api.github.com/user/emails', { headers: { ...auth, 'User-Agent': 'ts-analytics' } })
163
+ if (emailsRes.ok) {
164
+ const emails = await emailsRes.json() as Array<{ email: string, primary: boolean, verified: boolean }>
165
+ const primary = emails.find(e => e.primary && e.verified) || emails.find(e => e.verified)
166
+ if (primary) {
167
+ email = primary.email
168
+ verified = true
169
+ }
170
+ }
171
+ if (!email)
172
+ return null
173
+ return { email, emailVerified: verified, name: p.name || p.login || '', providerId: String(p.id || '') }
174
+ }
175
+ return null
176
+ }
177
+
178
+ function loginRedirect(reason: string): Response {
179
+ return new Response(null, { status: 302, headers: { Location: `/login?error=${encodeURIComponent(reason)}` } })
180
+ }
181
+
182
+ /** GET /api/auth/oauth/{provider}/callback — exchange, link/create, sign in. */
183
+ export async function handleOAuthCallback(request: Request, provider: string): Promise<Response> {
184
+ try {
185
+ const cfg = providerConfig(provider)
186
+ if (!cfg)
187
+ return jsonResponse({ error: `${provider} sign-in is not configured on this server` }, 503)
188
+
189
+ const url = new URL(request.url)
190
+ const code = url.searchParams.get('code') || ''
191
+ const state = url.searchParams.get('state') || ''
192
+ if (!code || !state)
193
+ return loginRedirect('oauth')
194
+
195
+ const stateProvider = await consumeState(state)
196
+ if (stateProvider !== provider)
197
+ return loginRedirect('oauth')
198
+
199
+ const accessToken = await exchangeCode(cfg, provider, code)
200
+ if (!accessToken)
201
+ return loginRedirect('oauth')
202
+
203
+ const profile = await fetchProfile(provider, accessToken)
204
+ if (!profile)
205
+ return loginRedirect('oauth')
206
+
207
+ // Only trust provider-verified emails to link to an existing account —
208
+ // otherwise an attacker could claim someone's address on the provider and
209
+ // take over their account here.
210
+ let user = await getUserByEmail(profile.email)
211
+ if (user && !profile.emailVerified)
212
+ return loginRedirect('oauth-unverified-email')
213
+
214
+ if (!user) {
215
+ const created = await createOAuthUser(profile.email, profile.name, profile.emailVerified)
216
+ await acceptInvites(created.userId, created.email)
217
+ user = { userId: created.userId, email: created.email }
218
+ }
219
+
220
+ // Record the provider link (idempotent) so the account shows how it signs in.
221
+ await updateUserFields(user.userId, { [`${provider}Id`]: profile.providerId, updatedAt: new Date().toISOString() })
222
+
223
+ const token = await createSession(user.userId, user.email)
224
+ return new Response(null, {
225
+ status: 302,
226
+ headers: { 'Location': '/dashboard', 'Set-Cookie': sessionCookie(token) },
227
+ })
228
+ }
229
+ catch (error) {
230
+ console.error('OAuth callback error:', error)
231
+ return errorResponse('OAuth sign-in failed')
232
+ }
233
+ }
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Performance & Web Vitals handlers
3
+ */
4
+
5
+ import { generateId } from '../index'
6
+ import { dynamodb, TABLE_NAME, unmarshall, marshall } from '../lib/dynamodb'
7
+ import { parseDateRange } from '../utils/date'
8
+ import { jsonResponse, errorResponse } from '../utils/response'
9
+ import { getQueryParams } from '../../deploy/lambda-adapter'
10
+
11
+ /**
12
+ * GET /api/sites/{siteId}/vitals
13
+ */
14
+ export async function handleGetVitals(request: Request, siteId: string): Promise<Response> {
15
+ try {
16
+ const query = getQueryParams(request)
17
+ const { startDate, endDate } = parseDateRange(query)
18
+
19
+ const result = await dynamodb.query({
20
+ TableName: TABLE_NAME,
21
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
22
+ ExpressionAttributeValues: {
23
+ ':pk': { S: `SITE#${siteId}` },
24
+ ':start': { S: `VITAL#${startDate.toISOString()}` },
25
+ ':end': { S: `VITAL#${endDate.toISOString()}` },
26
+ },
27
+ }) as { Items?: any[] }
28
+
29
+ const vitals = (result.Items || []).map(unmarshall)
30
+
31
+ // Aggregate vitals by metric
32
+ const metrics: Record<string, { values: number[]; paths: Record<string, number[]> }> = {
33
+ LCP: { values: [], paths: {} },
34
+ FID: { values: [], paths: {} },
35
+ CLS: { values: [], paths: {} },
36
+ FCP: { values: [], paths: {} },
37
+ TTFB: { values: [], paths: {} },
38
+ INP: { values: [], paths: {} },
39
+ }
40
+
41
+ for (const vital of vitals) {
42
+ const metric = vital.metric || vital.name
43
+ if (metrics[metric]) {
44
+ // CLS travels x1000 on the wire (the tracker rounds values to ints,
45
+ // which would floor raw CLS to 0) — normalize back to the real
46
+ // unitless value at read time so percentiles/ratings are correct (#133).
47
+ const value = metric === 'CLS' ? (vital.value || 0) / 1000 : vital.value
48
+ metrics[metric].values.push(value)
49
+ const path = vital.path || '/'
50
+ if (!metrics[metric].paths[path]) {
51
+ metrics[metric].paths[path] = []
52
+ }
53
+ metrics[metric].paths[path].push(value)
54
+ }
55
+ }
56
+
57
+ // Calculate percentiles
58
+ const calculatePercentile = (arr: number[], p: number) => {
59
+ if (arr.length === 0) return 0
60
+ const sorted = [...arr].sort((a, b) => a - b)
61
+ const idx = Math.ceil((p / 100) * sorted.length) - 1
62
+ return sorted[Math.max(0, idx)]
63
+ }
64
+
65
+ const getRating = (metric: string, value: number): 'good' | 'needs-improvement' | 'poor' => {
66
+ const thresholds: Record<string, [number, number]> = {
67
+ LCP: [2500, 4000],
68
+ FID: [100, 300],
69
+ CLS: [0.1, 0.25],
70
+ FCP: [1800, 3000],
71
+ TTFB: [800, 1800],
72
+ INP: [200, 500],
73
+ }
74
+ const [good, poor] = thresholds[metric] || [0, 0]
75
+ if (value <= good) return 'good'
76
+ if (value <= poor) return 'needs-improvement'
77
+ return 'poor'
78
+ }
79
+
80
+ // Shape matches the Vital contract the tab binds (metric/samples/
81
+ // good/needsImprovement/poor, src/types/analytics.ts) — the old
82
+ // {name,count,rating} response left every card rendering "0 samples".
83
+ const summary = Object.entries(metrics).map(([name, data]) => {
84
+ const samples = data.values.length
85
+ const buckets = { 'good': 0, 'needs-improvement': 0, 'poor': 0 }
86
+ for (const v of data.values) buckets[getRating(name, v)]++
87
+ const pct = (n: number): number => samples > 0 ? Math.round((n / samples) * 100) : 0
88
+ return {
89
+ metric: name,
90
+ name,
91
+ p50: calculatePercentile(data.values, 50),
92
+ p75: calculatePercentile(data.values, 75),
93
+ p95: calculatePercentile(data.values, 95),
94
+ samples,
95
+ count: samples,
96
+ good: pct(buckets.good),
97
+ needsImprovement: pct(buckets['needs-improvement']),
98
+ poor: pct(buckets.poor),
99
+ rating: getRating(name, calculatePercentile(data.values, 75)),
100
+ }
101
+ })
102
+
103
+ // Get worst performing pages
104
+ const worstPages: Array<{ path: string; metric: string; p75: number; rating: string }> = []
105
+ for (const [metric, data] of Object.entries(metrics)) {
106
+ for (const [path, values] of Object.entries(data.paths)) {
107
+ const p75 = calculatePercentile(values, 75)
108
+ const rating = getRating(metric, p75)
109
+ if (rating === 'poor' || rating === 'needs-improvement') {
110
+ worstPages.push({ path, metric, p75, rating })
111
+ }
112
+ }
113
+ }
114
+ worstPages.sort((a, b) => (b.rating === 'poor' ? 1 : 0) - (a.rating === 'poor' ? 1 : 0))
115
+
116
+ return jsonResponse({
117
+ vitals: summary,
118
+ worstPages: worstPages.slice(0, 10),
119
+ totalMeasurements: vitals.length,
120
+ dateRange: {
121
+ start: startDate.toISOString(),
122
+ end: endDate.toISOString(),
123
+ },
124
+ })
125
+ }
126
+ catch (error) {
127
+ console.error('Get vitals error:', error)
128
+ return errorResponse('Failed to fetch vitals')
129
+ }
130
+ }
131
+
132
+ /**
133
+ * GET /api/sites/{siteId}/vitals-trends
134
+ */
135
+ export async function handleGetVitalsTrends(request: Request, siteId: string): Promise<Response> {
136
+ try {
137
+ const query = getQueryParams(request)
138
+ const { startDate, endDate } = parseDateRange(query)
139
+ const metric = query.metric || 'LCP'
140
+
141
+ const result = await dynamodb.query({
142
+ TableName: TABLE_NAME,
143
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
144
+ ExpressionAttributeValues: {
145
+ ':pk': { S: `SITE#${siteId}` },
146
+ ':start': { S: `VITAL#${startDate.toISOString()}` },
147
+ ':end': { S: `VITAL#${endDate.toISOString()}` },
148
+ },
149
+ }) as { Items?: any[] }
150
+
151
+ const vitals = (result.Items || []).map(unmarshall)
152
+ .filter(v => (v.metric || v.name) === metric)
153
+
154
+ // Group by day
155
+ const dailyData: Record<string, number[]> = {}
156
+ for (const vital of vitals) {
157
+ const day = vital.timestamp.slice(0, 10)
158
+ if (!dailyData[day]) dailyData[day] = []
159
+ // CLS travels x1000 on the wire — normalize at read time (#133).
160
+ dailyData[day].push(metric === 'CLS' ? (vital.value || 0) / 1000 : vital.value)
161
+ }
162
+
163
+ const calculatePercentile = (arr: number[], p: number) => {
164
+ if (arr.length === 0) return 0
165
+ const sorted = [...arr].sort((a, b) => a - b)
166
+ const idx = Math.ceil((p / 100) * sorted.length) - 1
167
+ return sorted[Math.max(0, idx)]
168
+ }
169
+
170
+ const trends = Object.entries(dailyData)
171
+ .map(([date, values]) => ({
172
+ date,
173
+ p50: calculatePercentile(values, 50),
174
+ p75: calculatePercentile(values, 75),
175
+ p95: calculatePercentile(values, 95),
176
+ count: values.length,
177
+ }))
178
+ .sort((a, b) => a.date.localeCompare(b.date))
179
+
180
+ return jsonResponse({
181
+ metric,
182
+ trends,
183
+ dateRange: {
184
+ start: startDate.toISOString(),
185
+ end: endDate.toISOString(),
186
+ },
187
+ })
188
+ }
189
+ catch (error) {
190
+ console.error('Get vitals trends error:', error)
191
+ return errorResponse('Failed to fetch vitals trends')
192
+ }
193
+ }
194
+
195
+ /**
196
+ * POST /api/sites/{siteId}/performance-budgets
197
+ */
198
+ export async function handleCreatePerformanceBudget(request: Request, siteId: string): Promise<Response> {
199
+ try {
200
+ const body = await request.json() as Record<string, any>
201
+
202
+ if (!body.metric || body.threshold === undefined) {
203
+ return jsonResponse({ error: 'Missing required fields: metric, threshold' }, 400)
204
+ }
205
+
206
+ const validMetrics = ['LCP', 'FID', 'CLS', 'FCP', 'TTFB', 'INP']
207
+ if (!validMetrics.includes(body.metric)) {
208
+ return jsonResponse({ error: `Invalid metric. Must be one of: ${validMetrics.join(', ')}` }, 400)
209
+ }
210
+
211
+ const budgetId = generateId()
212
+ const budget = {
213
+ pk: `SITE#${siteId}`,
214
+ sk: `BUDGET#${budgetId}`,
215
+ id: budgetId,
216
+ siteId,
217
+ metric: body.metric,
218
+ threshold: body.threshold,
219
+ path: body.path || '*',
220
+ alertOnViolation: body.alertOnViolation ?? true,
221
+ createdAt: new Date().toISOString(),
222
+ }
223
+
224
+ await dynamodb.putItem({
225
+ TableName: TABLE_NAME,
226
+ Item: marshall(budget),
227
+ })
228
+
229
+ return jsonResponse({ budget }, 201)
230
+ }
231
+ catch (error) {
232
+ console.error('Create performance budget error:', error)
233
+ return errorResponse('Failed to create performance budget')
234
+ }
235
+ }
236
+
237
+ /**
238
+ * GET /api/sites/{siteId}/performance-budgets
239
+ */
240
+ export async function handleGetPerformanceBudgets(_request: Request, siteId: string): Promise<Response> {
241
+ try {
242
+ const result = await dynamodb.query({
243
+ TableName: TABLE_NAME,
244
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
245
+ ExpressionAttributeValues: {
246
+ ':pk': { S: `SITE#${siteId}` },
247
+ ':prefix': { S: 'BUDGET#' },
248
+ },
249
+ }) as { Items?: any[] }
250
+
251
+ const budgets = (result.Items || []).map(unmarshall)
252
+
253
+ return jsonResponse({ budgets })
254
+ }
255
+ catch (error) {
256
+ console.error('Get performance budgets error:', error)
257
+ return errorResponse('Failed to fetch performance budgets')
258
+ }
259
+ }
260
+
261
+ /**
262
+ * DELETE /api/sites/{siteId}/performance-budgets/{budgetId}
263
+ */
264
+ export async function handleDeletePerformanceBudget(_request: Request, siteId: string, budgetId: string): Promise<Response> {
265
+ try {
266
+ await dynamodb.deleteItem({
267
+ TableName: TABLE_NAME,
268
+ Key: marshall({
269
+ pk: `SITE#${siteId}`,
270
+ sk: `BUDGET#${budgetId}`,
271
+ }),
272
+ })
273
+
274
+ return jsonResponse({ success: true })
275
+ }
276
+ catch (error) {
277
+ console.error('Delete performance budget error:', error)
278
+ return errorResponse('Failed to delete performance budget')
279
+ }
280
+ }
281
+
282
+ /**
283
+ * GET /api/sites/{siteId}/performance-budgets/check
284
+ */
285
+ export async function handleCheckPerformanceBudgets(request: Request, siteId: string): Promise<Response> {
286
+ try {
287
+ const query = getQueryParams(request)
288
+ const { startDate, endDate } = parseDateRange(query)
289
+
290
+ // Get budgets
291
+ const budgetsResult = await dynamodb.query({
292
+ TableName: TABLE_NAME,
293
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
294
+ ExpressionAttributeValues: {
295
+ ':pk': { S: `SITE#${siteId}` },
296
+ ':prefix': { S: 'BUDGET#' },
297
+ },
298
+ }) as { Items?: any[] }
299
+
300
+ const budgets = (budgetsResult.Items || []).map(unmarshall)
301
+
302
+ if (budgets.length === 0) {
303
+ return jsonResponse({ budgets: [], violations: [] })
304
+ }
305
+
306
+ // Get vitals
307
+ const vitalsResult = await dynamodb.query({
308
+ TableName: TABLE_NAME,
309
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
310
+ ExpressionAttributeValues: {
311
+ ':pk': { S: `SITE#${siteId}` },
312
+ ':start': { S: `VITAL#${startDate.toISOString()}` },
313
+ ':end': { S: `VITAL#${endDate.toISOString()}` },
314
+ },
315
+ }) as { Items?: any[] }
316
+
317
+ const vitals = (vitalsResult.Items || []).map(unmarshall)
318
+
319
+ // Check each budget
320
+ const violations: Array<{
321
+ budgetId: string
322
+ metric: string
323
+ threshold: number
324
+ currentValue: number
325
+ currentP75: number
326
+ exceededBy: number
327
+ path: string
328
+ violationCount: number
329
+ }> = []
330
+
331
+ const calculatePercentile = (arr: number[], p: number) => {
332
+ if (arr.length === 0) return 0
333
+ const sorted = [...arr].sort((a, b) => a - b)
334
+ const idx = Math.ceil((p / 100) * sorted.length) - 1
335
+ return sorted[Math.max(0, idx)]
336
+ }
337
+
338
+ for (const budget of budgets) {
339
+ const relevantVitals = vitals.filter(v => {
340
+ const metric = v.metric || v.name
341
+ if (metric !== budget.metric) return false
342
+ if (budget.path && budget.path !== '*') {
343
+ return v.path === budget.path
344
+ }
345
+ return true
346
+ })
347
+
348
+ if (relevantVitals.length === 0) continue
349
+
350
+ // CLS travels x1000 on the wire — normalize before comparing against
351
+ // user-entered unitless thresholds like 0.1 (#133).
352
+ const values = relevantVitals.map(v => budget.metric === 'CLS' ? (v.value || 0) / 1000 : v.value)
353
+ const p75 = calculatePercentile(values, 75)
354
+ const violatingValues = values.filter(v => v > budget.threshold)
355
+
356
+ if (p75 > budget.threshold) {
357
+ violations.push({
358
+ budgetId: budget.id,
359
+ metric: budget.metric,
360
+ threshold: budget.threshold,
361
+ // currentValue/exceededBy are the BudgetViolation contract the tab
362
+ // renders; currentP75 kept for back-compat.
363
+ currentValue: p75,
364
+ currentP75: p75,
365
+ exceededBy: Number((p75 - budget.threshold).toFixed(3)),
366
+ path: budget.path || '*',
367
+ violationCount: violatingValues.length,
368
+ })
369
+ }
370
+ }
371
+
372
+ return jsonResponse({
373
+ budgets: budgets.map(b => ({
374
+ ...b,
375
+ hasViolation: violations.some(v => v.budgetId === b.id),
376
+ })),
377
+ violations,
378
+ dateRange: {
379
+ start: startDate.toISOString(),
380
+ end: endDate.toISOString(),
381
+ },
382
+ })
383
+ }
384
+ catch (error) {
385
+ console.error('Check performance budgets error:', error)
386
+ return errorResponse('Failed to check performance budgets')
387
+ }
388
+ }