@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.
- package/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- 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
|
+
}
|
package/src/lib/email.ts
ADDED
|
@@ -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
|
+
}
|