@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,32 @@
1
+ /**
2
+ * `useTsAnalytics()` — custom event tracking for the ts-analytics Nuxt module.
3
+ *
4
+ * The injected tracker exposes a Fathom-API-compatible global
5
+ * (`window.fathom.track(name, value)`); this composable wraps it with an
6
+ * SSR-safe no-op on the server. Auto-imported by `@stacksjs/ts-analytics/nuxt`.
7
+ *
8
+ * ```ts
9
+ * const { track } = useTsAnalytics()
10
+ * track('signup')
11
+ * track('purchase', 4200) // optional numeric value (e.g. cents)
12
+ * track('signup', 0, { plan: 'pro' }) // custom properties
13
+ * ```
14
+ */
15
+ export interface TsAnalyticsApi {
16
+ /** Track a custom event by name, with an optional numeric value and
17
+ * custom properties (Plausible-style) that appear under Event Properties. */
18
+ track: (name: string, value?: number, props?: Record<string, string | number | boolean>) => void
19
+ }
20
+
21
+ export function useTsAnalytics(): TsAnalyticsApi {
22
+ return {
23
+ track(name: string, value?: number, props?: Record<string, string | number | boolean>): void {
24
+ if (typeof window === 'undefined')
25
+ return // SSR: the tracker only exists in the browser
26
+ const tracker = (window as unknown as {
27
+ fathom?: { track?: (name: string, value?: number, props?: Record<string, string | number | boolean>) => void }
28
+ }).fathom
29
+ tracker?.track?.(name, value, props)
30
+ },
31
+ }
32
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * stx / Stacks Config Integration
3
+ *
4
+ * Add ts-analytics to a stx (or Stacks) app the way you'd add a Nuxt analytics
5
+ * module — from the config file, keyed by an **App ID**.
6
+ *
7
+ * ts-analytics serves one shared, cacheable tracker at `<apiEndpoint>/script.js`
8
+ * (Fathom/Plausible-style). The tracker reads its App ID from the `data-site`
9
+ * attribute and derives the collector endpoint from its own `src` origin, so
10
+ * the only thing a host app supplies is the App ID + the API origin.
11
+ *
12
+ * Spread the result into your stx config's `app.head.script`:
13
+ *
14
+ * ```ts
15
+ * // config/stx.ts (Stacks) — or stx.config.ts
16
+ * import { tsAnalytics } from '@stacksjs/ts-analytics/stx'
17
+ *
18
+ * export default {
19
+ * app: {
20
+ * head: {
21
+ * script: [
22
+ * ...tsAnalytics({ appId: 'my-app' }), // endpoint is baked in
23
+ * ],
24
+ * },
25
+ * },
26
+ * }
27
+ * ```
28
+ *
29
+ * **Why a head helper and not a `plugins: [...]` entry?** stx's plugin render
30
+ * lifecycle hooks (`afterRender`) are not invoked by the dev/serve render
31
+ * pipeline, and a plugin's `setup()` config mutation does not propagate to
32
+ * rendering. The head config *is* read on every render, so this is the reliable
33
+ * mechanism — and it mirrors what framework analytics modules do internally
34
+ * (push a tag onto the document head).
35
+ */
36
+
37
+ /**
38
+ * Generate a short, unguessable App ID (Fathom-style `data-site`, 8 chars). Run
39
+ * once per site and paste the result into your config — the backend
40
+ * auto-provisions the site on its first event, so nothing else is needed.
41
+ *
42
+ * ```ts
43
+ * import { generateAppId } from '@stacksjs/ts-analytics/stx'
44
+ * console.log(generateAppId()) // e.g. 'K7MN4PQR'
45
+ * ```
46
+ */
47
+ export { generateAppId } from '../lib/crypto-random'
48
+
49
+ /**
50
+ * Default ts-analytics API origin — where `/script.js` is served and events are
51
+ * collected. Baked in (Fathom-style) so host apps supply *only* an App ID.
52
+ *
53
+ * Endpoint resolution order: an explicit `apiEndpoint` option → the
54
+ * `TS_ANALYTICS_ENDPOINT` env var → this constant. Set this to your production
55
+ * ts-analytics host so deployed apps need zero endpoint config.
56
+ */
57
+ export const DEFAULT_API_ENDPOINT = 'http://localhost:2027'
58
+
59
+ /** Resolve the API origin from an explicit override, env, then the default. */
60
+ export function resolveApiEndpoint(explicit?: string): string {
61
+ const raw = (explicit ?? process.env.TS_ANALYTICS_ENDPOINT ?? DEFAULT_API_ENDPOINT).trim()
62
+ return raw.replace(/\/+$/, '')
63
+ }
64
+
65
+ /** A stx head `<script>` entry — an item of the config `app.head.script` array. */
66
+ export interface StxHeadScript {
67
+ src?: string
68
+ content?: string
69
+ defer?: boolean
70
+ async?: boolean
71
+ type?: string
72
+ /** Arbitrary attributes (e.g. `data-site`) are rendered verbatim. */
73
+ [attr: string]: unknown
74
+ }
75
+
76
+ export interface TsAnalyticsOptions {
77
+ /**
78
+ * Your ts-analytics **App ID** — rendered as the tracker's `data-site`. A long,
79
+ * unguessable random string (see {@link generateAppId}); the backend
80
+ * auto-provisions the site on its first event, so no pre-registration needed.
81
+ */
82
+ appId: string
83
+ /**
84
+ * Origin of your ts-analytics API — where `/script.js` is served and where the
85
+ * tracker POSTs. **Optional**: defaults to the `TS_ANALYTICS_ENDPOINT` env var,
86
+ * then {@link DEFAULT_API_ENDPOINT}. Set this only for a one-off override — the
87
+ * point is that host apps supply *just* an App ID (Fathom-style). Trailing
88
+ * slashes are trimmed.
89
+ */
90
+ apiEndpoint?: string
91
+ /** Path of the shared tracker script. Default `'/script.js'`. */
92
+ scriptPath?: string
93
+ /** Load the tracker in stealth mode (appends `?stealth=true`). Default false. */
94
+ stealth?: boolean
95
+ }
96
+
97
+ /**
98
+ * Build the ts-analytics head `<script>` entry for a stx/Stacks app config.
99
+ *
100
+ * Returns an **array** (so it spreads cleanly into `app.head.script` and can
101
+ * grow later — e.g. an error-tracking tag — without changing call sites).
102
+ * Returns `[]` when `appId`/`apiEndpoint` are missing, so a half-configured
103
+ * environment never injects a broken tag.
104
+ */
105
+ export function tsAnalytics(options: TsAnalyticsOptions): StxHeadScript[] {
106
+ const appId = options?.appId?.trim()
107
+ if (!appId)
108
+ return []
109
+
110
+ const origin = resolveApiEndpoint(options?.apiEndpoint)
111
+ if (!origin)
112
+ return []
113
+ const rawPath = options.scriptPath ?? '/script.js'
114
+ const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`
115
+ const src = `${origin}${path}${options.stealth ? '?stealth=true' : ''}`
116
+
117
+ return [
118
+ {
119
+ src,
120
+ defer: true,
121
+ 'data-site': appId,
122
+ },
123
+ ]
124
+ }
125
+
126
+ /**
127
+ * Raw `<script>` tag string for non-stx contexts — paste into a plain HTML
128
+ * `<head>`, or inject server-side. Same inputs as {@link tsAnalytics}. Returns
129
+ * `''` when required options are missing.
130
+ */
131
+ export function tsAnalyticsTag(options: TsAnalyticsOptions): string {
132
+ const [entry] = tsAnalytics(options)
133
+ if (!entry)
134
+ return ''
135
+ return `<script defer data-site="${String(entry['data-site'])}" src="${String(entry.src)}"></script>`
136
+ }
137
+
138
+ export default tsAnalytics
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Job registration bootstrap.
3
+ *
4
+ * Called once at startup to register every periodic job with the scheduler.
5
+ * Feature jobs (error alerts #80, uptime checks #91, webhook delivery #92,
6
+ * email digests #93) register here.
7
+ */
8
+ import { registerJob } from '../lib/scheduler'
9
+ import { runUptimeChecks } from '../handlers/uptime'
10
+ import { sendDueEmailDigests } from '../handlers/alerts'
11
+ import { processWebhookDeliveries } from '../handlers/webhooks'
12
+ import { runDailyRollups } from '../lib/rollups'
13
+
14
+ let bootstrapped = false
15
+
16
+ export function bootstrapJobs(): void {
17
+ if (bootstrapped)
18
+ return
19
+ bootstrapped = true
20
+
21
+ // Heartbeat — proves the runtime ticks + persists last-run end to end.
22
+ registerJob({
23
+ name: 'heartbeat',
24
+ intervalMs: 60_000,
25
+ run: async () => {
26
+ console.log(`[jobs] heartbeat ${new Date().toISOString()}`)
27
+ },
28
+ })
29
+
30
+ // Ingest-drop watchdog (#175): a site whose collected-beacon count falls
31
+ // off a cliff day-over-day is silently broken (CORS, firewall misconfig,
32
+ // removed snippet) — exactly the failure mode that shipped twice unnoticed.
33
+ registerJob({
34
+ name: 'ingest-drop-watch',
35
+ intervalMs: 60 * 60_000,
36
+ run: async () => {
37
+ const { queryAllItems, unmarshall, dynamodb, TABLE_NAME } = await import('../lib/dynamodb')
38
+ const { log } = await import('../lib/log')
39
+ const sitesRes = await dynamodb.query({
40
+ TableName: TABLE_NAME,
41
+ KeyConditionExpression: 'pk = :pk',
42
+ ExpressionAttributeValues: { ':pk': { S: 'SITES' } },
43
+ }) as { Items?: any[] }
44
+ const now = Date.now()
45
+ const hourIso = (ms: number): string => new Date(ms).toISOString().slice(0, 13)
46
+ for (const raw of (sitesRes.Items || [])) {
47
+ const site = unmarshall(raw)
48
+ const siteId = site.id || site.siteId
49
+ if (!siteId)
50
+ continue
51
+ const res = await queryAllItems({
52
+ TableName: TABLE_NAME,
53
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
54
+ ExpressionAttributeValues: {
55
+ ':pk': { S: `SITE#${siteId}` },
56
+ ':start': { S: `INGEST#${hourIso(now - 48 * 3600_000)}` },
57
+ ':end': { S: `INGEST#${hourIso(now)}~` },
58
+ },
59
+ })
60
+ let recent = 0
61
+ let previous = 0
62
+ for (const item of (res.Items || []).map(unmarshall)) {
63
+ const hour = String(item.sk).slice('INGEST#'.length)
64
+ const n = Number(item.collected || 0)
65
+ if (hour >= hourIso(now - 24 * 3600_000))
66
+ recent += n
67
+ else previous += n
68
+ }
69
+ // Alert only with a meaningful baseline; 70%+ drop is a cliff.
70
+ if (previous >= 100 && recent < previous * 0.3) {
71
+ log.error('ingest.drop_detected', { siteId, previous24h: previous, recent24h: recent })
72
+ }
73
+ }
74
+ },
75
+ })
76
+
77
+ // Uptime checks — probe each due monitor's URL and record up/down + latency,
78
+ // notifying on a down transition (#91). Runs every minute; each monitor is
79
+ // probed on its own configured interval.
80
+ registerJob({
81
+ name: 'uptime-checks',
82
+ intervalMs: 60_000,
83
+ run: async () => {
84
+ const n = await runUptimeChecks()
85
+ if (n > 0)
86
+ console.log(`[jobs] uptime-checks: probed ${n} monitor(s)`)
87
+ },
88
+ })
89
+
90
+ // Email digests — send each email-report on its schedule (daily/weekly/
91
+ // monthly) with the period's pageview summary (#93). Checked hourly; the
92
+ // per-report schedule gates the actual send.
93
+ registerJob({
94
+ name: 'email-digests',
95
+ intervalMs: 60 * 60_000,
96
+ run: async () => {
97
+ const n = await sendDueEmailDigests()
98
+ if (n > 0)
99
+ console.log(`[jobs] email-digests: sent ${n} report(s)`)
100
+ },
101
+ })
102
+
103
+ // Webhook delivery — drain the queue, POST each event (HMAC-signed) with
104
+ // retry/backoff (#92).
105
+ registerJob({
106
+ name: 'webhook-delivery',
107
+ intervalMs: 60_000,
108
+ run: async () => {
109
+ const n = await processWebhookDeliveries()
110
+ if (n > 0)
111
+ console.log(`[jobs] webhook-delivery: processed ${n} delivery(s)`)
112
+ },
113
+ })
114
+
115
+ // Daily rollups — pre-aggregate each site's complete days so /stats and
116
+ // /timeseries read O(buckets) instead of re-scanning raw events (#94).
117
+ // Hourly cadence: idempotent, so most runs are no-ops once days are covered.
118
+ registerJob({
119
+ name: 'daily-rollups',
120
+ intervalMs: 60 * 60_000,
121
+ run: async () => {
122
+ const n = await runDailyRollups()
123
+ if (n > 0)
124
+ console.log(`[jobs] daily-rollups: wrote ${n} day rollup(s)`)
125
+ },
126
+ })
127
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Cryptographically-secure random tokens.
3
+ *
4
+ * `Math.random()` is not a CSPRNG — its output is predictable, so anything that
5
+ * must be unguessable (API keys, share tokens, ids used as capabilities) has to
6
+ * come from `crypto.getRandomValues()`. Rejection sampling avoids the modulo
7
+ * bias you'd get from `byte % charset.length`. (#130)
8
+ */
9
+ const DEFAULT_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
10
+
11
+ /** A random string of `length` chars drawn uniformly from `chars`. */
12
+ export function randomToken(length: number, chars: string = DEFAULT_CHARS): string {
13
+ if (length <= 0)
14
+ return ''
15
+ // Largest multiple of charset length that fits in a byte; reject above it so
16
+ // every character is equally likely.
17
+ const limit = Math.floor(256 / chars.length) * chars.length
18
+ let out = ''
19
+ const buf = new Uint8Array(length * 2)
20
+ while (out.length < length) {
21
+ crypto.getRandomValues(buf)
22
+ for (let i = 0; i < buf.length && out.length < length; i++) {
23
+ if (buf[i] < limit)
24
+ out += chars[buf[i] % chars.length]
25
+ }
26
+ }
27
+ return out
28
+ }
29
+
30
+ /**
31
+ * Site / App IDs — short, unambiguous, Fathom-style (`WOLZMJDL`). Uppercase +
32
+ * digits with the ambiguous chars (0/O/1/I) removed, so an id is easy to eyeball
33
+ * in DynamoDB and logs. 8 chars over a 32-char alphabet ≈ 1e12 combinations —
34
+ * plenty of headroom for site ids while staying short.
35
+ */
36
+ const APP_ID_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
37
+
38
+ /** A short, random, unguessable App ID (default 8 chars). */
39
+ export function generateAppId(length: number = 8): string {
40
+ return randomToken(length, APP_ID_CHARS)
41
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * DynamoDB error classification. Lives in its own dependency-free module so
3
+ * both src/lib/dynamodb and the ORM can import it without creating a circular
4
+ * import (the ORM must never import lib/dynamodb — lib/dynamodb imports the
5
+ * ORM, and the cycle trips a TDZ depending on entry order, #177).
6
+ */
7
+
8
+ /**
9
+ * Whether an error is a DynamoDB conditional-check failure. Matches BOTH the
10
+ * AWS SDK shape (name === 'ConditionalCheckFailedException') and the ts-cloud
11
+ * client's wrapping (generic Error whose message contains the DynamoDB
12
+ * error text "The conditional request failed").
13
+ */
14
+ export function isConditionalCheckFailed(e: unknown): boolean {
15
+ const err = e as { name?: string, message?: string } | null
16
+ return !!err && (
17
+ String(err.name || '').includes('ConditionalCheckFailed')
18
+ || /conditional request failed/i.test(String(err.message || err))
19
+ )
20
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * DynamoDB client configuration and utilities
3
+ */
4
+
5
+ import {
6
+ configureAnalytics,
7
+ createClient,
8
+ marshall,
9
+ unmarshall,
10
+ } from '../../src/models/orm'
11
+
12
+ // Configuration
13
+ export const TABLE_NAME: string = process.env.ANALYTICS_TABLE_NAME || 'ts-analytics'
14
+ export const REGION: string = process.env.AWS_REGION || 'us-east-1'
15
+ /**
16
+ * Optional endpoint override (#177): point every DynamoDB client at a local
17
+ * instance (DynamoDB Local, or the in-process fake the test harness starts)
18
+ * instead of AWS. Unset in production.
19
+ */
20
+ export const DYNAMODB_ENDPOINT: string | undefined = process.env.ANALYTICS_DYNAMODB_ENDPOINT || undefined
21
+
22
+ // Configure analytics models on module load
23
+ configureAnalytics({
24
+ tableName: TABLE_NAME,
25
+ region: REGION,
26
+ ...(DYNAMODB_ENDPOINT ? { endpoint: DYNAMODB_ENDPOINT } : {}),
27
+ })
28
+
29
+ // Create native DynamoDB client for direct queries (used in dashboard handlers)
30
+ export const dynamodb: ReturnType<typeof createClient> = createClient({ region: REGION, ...(DYNAMODB_ENDPOINT ? { endpoint: DYNAMODB_ENDPOINT } : {}) })
31
+
32
+ // Re-export marshalling utilities
33
+ export { marshall, unmarshall }
34
+
35
+ /**
36
+ * Whether an error is a DynamoDB conditional-check failure. Matches BOTH the
37
+ * AWS SDK shape (name === 'ConditionalCheckFailedException') and the ts-cloud
38
+ * client's wrapped form (a generic Error whose message contains
39
+ * "The conditional request failed") — code that checked only e.name silently
40
+ * misclassified expected condition failures as real errors.
41
+ */
42
+ export { isConditionalCheckFailed } from './ddb-errors'
43
+
44
+
45
+ /**
46
+ * Build a DynamoDB query expression for date range
47
+ */
48
+ export function buildDateRangeExpression(
49
+ startDate: Date,
50
+ endDate: Date,
51
+ timestampField = 'timestamp'
52
+ ): {
53
+ expression: string
54
+ names: Record<string, string>
55
+ values: Record<string, unknown>
56
+ } {
57
+ return {
58
+ expression: `#${timestampField} BETWEEN :startDate AND :endDate`,
59
+ names: { [`#${timestampField}`]: timestampField },
60
+ values: {
61
+ ':startDate': { S: startDate.toISOString() },
62
+ ':endDate': { S: endDate.toISOString() },
63
+ },
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Build a DynamoDB key condition for site queries
69
+ */
70
+ export function buildSiteKeyCondition(siteId: string): {
71
+ expression: string
72
+ values: Record<string, unknown>
73
+ } {
74
+ return {
75
+ expression: 'pk = :pk',
76
+ values: { ':pk': { S: `SITE#${siteId}` } },
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Execute a paginated DynamoDB query
82
+ */
83
+ export async function paginatedQuery<T>(
84
+ params: {
85
+ TableName: string
86
+ KeyConditionExpression: string
87
+ ExpressionAttributeValues: Record<string, unknown>
88
+ ExpressionAttributeNames?: Record<string, string>
89
+ FilterExpression?: string
90
+ Limit?: number
91
+ ScanIndexForward?: boolean
92
+ IndexName?: string
93
+ },
94
+ maxItems = 1000
95
+ ): Promise<T[]> {
96
+ const items: T[] = []
97
+ let lastKey: Record<string, unknown> | undefined
98
+
99
+ do {
100
+ const response = await dynamodb.query({
101
+ ...params,
102
+ ExclusiveStartKey: lastKey,
103
+ })
104
+
105
+ if (response.Items) {
106
+ for (const item of response.Items) {
107
+ items.push(unmarshall(item) as T)
108
+ if (items.length >= maxItems) {
109
+ return items
110
+ }
111
+ }
112
+ }
113
+
114
+ lastKey = response.LastEvaluatedKey
115
+ } while (lastKey)
116
+
117
+ return items
118
+ }
119
+
120
+ /**
121
+ * All sessions for a site whose startedAt falls in [startDate, endDate],
122
+ * via the time-keyed GSI entry (#171) — replaces the full-partition
123
+ * begins_with(sk, SESSION#) scans that silently truncated at scale. Returns
124
+ * marshalled Items (same shape as queryAllItems) so call sites keep their
125
+ * existing unmarshall + filter pipelines.
126
+ */
127
+ export async function querySessionItemsInRange(
128
+ siteId: string,
129
+ startDate: Date,
130
+ endDate: Date,
131
+ ): Promise<{ Items: any[], Count: number }> {
132
+ return queryAllItems({
133
+ TableName: TABLE_NAME,
134
+ IndexName: 'GSI1',
135
+ KeyConditionExpression: 'gsi1pk = :pk AND gsi1sk BETWEEN :start AND :end',
136
+ ExpressionAttributeValues: {
137
+ ':pk': { S: `SITE#${siteId}#SESSIONS` },
138
+ ':start': { S: startDate.toISOString() },
139
+ ':end': { S: `${endDate.toISOString()}~` },
140
+ },
141
+ })
142
+ }
143
+
144
+ /**
145
+ * Query every page of a key condition and return the raw (still-marshalled)
146
+ * items. Drop-in for `dynamodb.query(...)` at call sites that read `.Items` and
147
+ * `.map(unmarshall)` themselves, but it no longer silently truncates at
148
+ * DynamoDB's 1MB page limit (#151). A high safety cap guards against pathological
149
+ * partitions and is logged when hit — never a silent drop.
150
+ */
151
+ export async function queryAllItems(
152
+ params: {
153
+ TableName: string
154
+ KeyConditionExpression: string
155
+ ExpressionAttributeValues: Record<string, unknown>
156
+ ExpressionAttributeNames?: Record<string, string>
157
+ FilterExpression?: string
158
+ Limit?: number
159
+ ScanIndexForward?: boolean
160
+ IndexName?: string
161
+ },
162
+ cap = 100_000,
163
+ ): Promise<{ Items: any[], Count: number }> {
164
+ const items: any[] = []
165
+ let lastKey: Record<string, any> | undefined
166
+ do {
167
+ const page = await dynamodb.query({ ...params, ExclusiveStartKey: lastKey }) as { Items?: any[], LastEvaluatedKey?: Record<string, any> }
168
+ if (page.Items)
169
+ items.push(...page.Items)
170
+ lastKey = page.LastEvaluatedKey
171
+ if (items.length >= cap) {
172
+ console.warn(`[queryAllItems] hit ${cap}-item cap (table ${String(params.TableName)}) — results truncated; counts may undercount`)
173
+ break
174
+ }
175
+ } while (lastKey)
176
+ return { Items: items, Count: items.length }
177
+ }
178
+
179
+ /**
180
+ * Batch get items from DynamoDB
181
+ */
182
+ export async function batchGet<T>(
183
+ keys: Array<{ pk: string; sk: string }>
184
+ ): Promise<T[]> {
185
+ if (keys.length === 0) return []
186
+
187
+ const items: T[] = []
188
+ const batches: Array<Array<{ pk: string; sk: string }>> = []
189
+
190
+ // Split into batches of 100 (DynamoDB limit)
191
+ for (let i = 0; i < keys.length; i += 100) {
192
+ batches.push(keys.slice(i, i + 100))
193
+ }
194
+
195
+ for (const batch of batches) {
196
+ const response = await dynamodb.batchGetItem({
197
+ RequestItems: {
198
+ [TABLE_NAME]: {
199
+ Keys: batch.map(key => ({
200
+ pk: { S: key.pk },
201
+ sk: { S: key.sk },
202
+ })),
203
+ },
204
+ },
205
+ })
206
+
207
+ const tableItems = response.Responses?.[TABLE_NAME]
208
+ if (tableItems) {
209
+ for (const item of tableItems) {
210
+ items.push(unmarshall(item) as T)
211
+ }
212
+ }
213
+ }
214
+
215
+ return items
216
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pluggable transactional email sender.
3
+ *
4
+ * Until a real provider (SES/SMTP/Resend) is configured via env, emails are
5
+ * logged to the server console so verification / reset links are usable in
6
+ * development. Wire a provider in `deliver()` for production.
7
+ */
8
+
9
+ export interface EmailMessage {
10
+ to: string
11
+ subject: string
12
+ text: string
13
+ html?: string
14
+ }
15
+
16
+ /** Base URL used to build links in emails (falls back to localhost in dev). */
17
+ export function appBaseUrl(): string {
18
+ // Dev fallback points at the dashboard, which proxies /api to this server —
19
+ // follows DASHBOARD_PORT (default 2026) so reset/verify/OAuth links resolve.
20
+ return process.env.ANALYTICS_APP_URL
21
+ || process.env.STX_PUBLIC_API_ENDPOINT
22
+ || `http://localhost:${process.env.DASHBOARD_PORT || '2026'}`
23
+ }
24
+
25
+ async function deliver(msg: EmailMessage): Promise<void> {
26
+ // TODO: plug a real provider here (SES via @aws-sdk/client-ses, SMTP, Resend…)
27
+ // when ANALYTICS_EMAIL_PROVIDER is configured. Until then, log so dev works.
28
+ console.log(`[email] to=${msg.to} | ${msg.subject}\n${msg.text}\n`)
29
+ }
30
+
31
+ export async function sendEmail(msg: EmailMessage): Promise<void> {
32
+ try {
33
+ await deliver(msg)
34
+ }
35
+ catch (e) {
36
+ console.error('[email] delivery failed:', (e as Error).message)
37
+ }
38
+ }