lytx 0.3.0
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/.env.example +37 -0
- package/README.md +486 -0
- package/alchemy.run.ts +155 -0
- package/cli/bootstrap-admin.ts +284 -0
- package/cli/deploy-staging.ts +692 -0
- package/cli/import-events.ts +628 -0
- package/cli/import-sites.ts +518 -0
- package/cli/index.ts +609 -0
- package/cli/init-db.ts +269 -0
- package/cli/migrate-to-durable-objects.ts +564 -0
- package/cli/migration-worker.ts +300 -0
- package/cli/performance-test.ts +588 -0
- package/cli/pg/client.ts +4 -0
- package/cli/pg/new-site.ts +153 -0
- package/cli/rollback-durable-objects.ts +622 -0
- package/cli/seed-data.ts +459 -0
- package/cli/setup.js +18 -0
- package/cli/setup.ts +463 -0
- package/cli/validate-migration.ts +200 -0
- package/cli/wrangler-migration.jsonc +28 -0
- package/db/adapter.ts +166 -0
- package/db/analytics_engine/client.ts +0 -0
- package/db/analytics_engine/sites.ts +0 -0
- package/db/client.ts +16 -0
- package/db/d1/client.ts +8 -0
- package/db/d1/drizzle.config.ts +35 -0
- package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
- package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
- package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
- package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
- package/db/d1/migrations/0004_mute_stardust.sql +1 -0
- package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
- package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
- package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
- package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
- package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
- package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
- package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
- package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
- package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
- package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
- package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
- package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
- package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
- package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
- package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
- package/db/d1/migrations/meta/_journal.json +76 -0
- package/db/d1/schema.ts +407 -0
- package/db/d1/sites.ts +374 -0
- package/db/d1/teamAiUsage.ts +101 -0
- package/db/d1/teams.ts +127 -0
- package/db/durable/drizzle.config.ts +8 -0
- package/db/durable/durableObjectClient.ts +480 -0
- package/db/durable/events.ts +100 -0
- package/db/durable/migrations/0000_fair_bucky.sql +38 -0
- package/db/durable/migrations/meta/0000_snapshot.json +278 -0
- package/db/durable/migrations/meta/_journal.json +13 -0
- package/db/durable/migrations/migrations.js +10 -0
- package/db/durable/schema.ts +5 -0
- package/db/durable/siteDurableObject.ts +1352 -0
- package/db/durable/types.ts +53 -0
- package/db/postgres/client.ts +13 -0
- package/db/postgres/drizzle.config.ts +12 -0
- package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
- package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
- package/db/postgres/migrations/meta/_journal.json +13 -0
- package/db/postgres/schema.ts +145 -0
- package/db/postgres/sites.ts +118 -0
- package/db/tranformReports.ts +595 -0
- package/db/types.ts +55 -0
- package/endpoints/api_worker.tsx +1854 -0
- package/endpoints/site_do_worker.ts +11 -0
- package/index.d.ts +63 -0
- package/index.ts +83 -0
- package/lib/auth.ts +279 -0
- package/lib/geojson/world_countries.json +45307 -0
- package/lib/random_name.ts +41 -0
- package/lib/sendMail.ts +252 -0
- package/package.json +142 -0
- package/public/favicon.ico +0 -0
- package/public/images/android-chrome-192x192.png +0 -0
- package/public/images/android-chrome-512x512.png +0 -0
- package/public/images/apple-touch-icon.png +0 -0
- package/public/images/favicon-16x16.png +0 -0
- package/public/images/favicon-32x32.png +0 -0
- package/public/images/lytx_dark_dashboard.png +0 -0
- package/public/images/lytx_light_dashboard.png +0 -0
- package/public/images/safari-pinned-tab.svg +4 -0
- package/public/logo.png +0 -0
- package/public/site.webmanifest +26 -0
- package/public/sw.js +107 -0
- package/src/Document.tsx +86 -0
- package/src/api/ai_api.ts +1156 -0
- package/src/api/authMiddleware.ts +45 -0
- package/src/api/auth_api.ts +465 -0
- package/src/api/event_labels_api.ts +193 -0
- package/src/api/events_api.ts +210 -0
- package/src/api/queueWorker.ts +303 -0
- package/src/api/reports_api.ts +278 -0
- package/src/api/seed_api.ts +288 -0
- package/src/api/sites_api.ts +904 -0
- package/src/api/tag_api.ts +458 -0
- package/src/api/tag_api_v2.ts +289 -0
- package/src/api/team_api.ts +456 -0
- package/src/app/Dashboard.tsx +1339 -0
- package/src/app/Events.tsx +974 -0
- package/src/app/Explore.tsx +312 -0
- package/src/app/Layout.tsx +58 -0
- package/src/app/Settings.tsx +1302 -0
- package/src/app/components/DashboardCard.tsx +118 -0
- package/src/app/components/EditableCell.tsx +123 -0
- package/src/app/components/EventForm.tsx +93 -0
- package/src/app/components/MarketingFooter.tsx +49 -0
- package/src/app/components/MarketingNav.tsx +150 -0
- package/src/app/components/Nav.tsx +755 -0
- package/src/app/components/NewSiteSetup.tsx +298 -0
- package/src/app/components/SQLEditor.tsx +740 -0
- package/src/app/components/SiteSelector.tsx +126 -0
- package/src/app/components/SiteTag.tsx +42 -0
- package/src/app/components/SiteTagInstallCard.tsx +241 -0
- package/src/app/components/WorldMapCard.tsx +337 -0
- package/src/app/components/charts/ChartComponents.tsx +1481 -0
- package/src/app/components/charts/EventFunnel.tsx +45 -0
- package/src/app/components/charts/EventSummary.tsx +194 -0
- package/src/app/components/charts/SankeyFlows.tsx +72 -0
- package/src/app/components/marketing/CheckIcon.tsx +16 -0
- package/src/app/components/marketing/MarketingLayout.tsx +23 -0
- package/src/app/components/marketing/SectionHeading.tsx +35 -0
- package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
- package/src/app/components/reports/CreateReportStarter.tsx +74 -0
- package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
- package/src/app/components/reports/DashboardToolbar.tsx +154 -0
- package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
- package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
- package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
- package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
- package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
- package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
- package/src/app/components/reports/custom/chartPalettes.ts +18 -0
- package/src/app/components/reports/custom/types.ts +50 -0
- package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
- package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
- package/src/app/components/ui/AlertBanner.tsx +101 -0
- package/src/app/components/ui/Button.tsx +55 -0
- package/src/app/components/ui/Card.tsx +80 -0
- package/src/app/components/ui/Input.tsx +72 -0
- package/src/app/components/ui/Link.tsx +23 -0
- package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
- package/src/app/components/ui/ThemeToggle.tsx +54 -0
- package/src/app/constants.ts +6 -0
- package/src/app/headers.ts +33 -0
- package/src/app/providers/AuthProvider.tsx +189 -0
- package/src/app/providers/ClientProviders.tsx +18 -0
- package/src/app/providers/QueryProvider.tsx +23 -0
- package/src/app/providers/ThemeProvider.tsx +88 -0
- package/src/app/utils/chartThemes.ts +146 -0
- package/src/app/utils/keybinds.ts +96 -0
- package/src/app/utils/media.tsx +24 -0
- package/src/client.tsx +114 -0
- package/src/config/createLytxAppConfig.ts +252 -0
- package/src/config/resourceNames.ts +88 -0
- package/src/db/index.ts +67 -0
- package/src/index.css +285 -0
- package/src/lib/featureFlags.ts +69 -0
- package/src/pages/GetStarted.tsx +290 -0
- package/src/pages/Home.tsx +268 -0
- package/src/pages/Login.tsx +283 -0
- package/src/pages/PrivacyPolicy.tsx +120 -0
- package/src/pages/Signup.tsx +267 -0
- package/src/pages/TermsOfService.tsx +126 -0
- package/src/pages/VerifyEmail.tsx +56 -0
- package/src/session/durableObject.ts +7 -0
- package/src/session/siteSchema.ts +86 -0
- package/src/session/types.ts +36 -0
- package/src/templates/README.md +80 -0
- package/src/templates/cleanFunctions.js +44 -0
- package/src/templates/embedFunctions.js +52 -0
- package/src/templates/lytx-shared.ts +662 -0
- package/src/templates/lytxpixel-core.ts +144 -0
- package/src/templates/lytxpixel.ts +267 -0
- package/src/templates/lytxpixelBrowser.js +634 -0
- package/src/templates/lytxpixelBrowser.mjs +634 -0
- package/src/templates/parseData.js +12 -0
- package/src/templates/script.ts +31 -0
- package/src/templates/template.tsx +50 -0
- package/src/templates/test.js +3 -0
- package/src/templates/trackWebEvents.ts +177 -0
- package/src/templates/vendors/clickcease.ts +8 -0
- package/src/templates/vendors/google.ts +174 -0
- package/src/templates/vendors/linkedin.ts +23 -0
- package/src/templates/vendors/meta.ts +56 -0
- package/src/templates/vendors/quantcast.ts +22 -0
- package/src/templates/vendors/simplfi.ts +7 -0
- package/src/types/app-context.ts +16 -0
- package/src/utilities/dashboardParams.ts +188 -0
- package/src/utilities/dashboardQueries.ts +537 -0
- package/src/utilities/dashboardTransforms.ts +167 -0
- package/src/utilities/dataValidation.ts +414 -0
- package/src/utilities/detector.ts +73 -0
- package/src/utilities/encrypt.ts +103 -0
- package/src/utilities/index.ts +13 -0
- package/src/utilities/parser.ts +117 -0
- package/src/utilities/performanceMonitoring.ts +570 -0
- package/src/utilities/route_interuptors.ts +24 -0
- package/src/worker.tsx +675 -0
- package/tsconfig.json +78 -0
- package/types/env.d.ts +16 -0
- package/types/rw.d.ts +7 -0
- package/types/shims.d.ts +53 -0
- package/types/vite.d.ts +19 -0
- package/vite/vite-plugin-pixel-bundle.ts +126 -0
- package/vite.config.ts +53 -0
- package/worker-configuration.d.ts +8401 -0
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import { drizzle, DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite';
|
|
3
|
+
import { eq, and, gte, lte, desc, asc, count, sql, isNotNull, ne, like, or, isNull, not } from "drizzle-orm";
|
|
4
|
+
import { siteEvents, type SiteEventInsert, type SiteEventSelect } from '@db/durable/schema';
|
|
5
|
+
import { migrate } from 'drizzle-orm/durable-sqlite/migrator';
|
|
6
|
+
//TODO: generate durable object migrations
|
|
7
|
+
import * as schema from "@db/durable/schema";
|
|
8
|
+
import migrations from '@db/durable/migrations/migrations';
|
|
9
|
+
import { SiteEventInput } from './types';
|
|
10
|
+
import type { GetEventsOptions, Durable_DB } from "@db/durable/types";
|
|
11
|
+
import { events_t, getEvents, type GetEventResult } from "@db/durable/events";
|
|
12
|
+
import { getSiteRidConfig, rotateSiteRidSalt } from "@db/d1/sites";
|
|
13
|
+
//TODO: Move this type
|
|
14
|
+
|
|
15
|
+
const MAX_SQL_ROWS = 500;
|
|
16
|
+
const SQL_ALLOWED_PREFIX = /^(select|with)\s/i;
|
|
17
|
+
const SQL_FORBIDDEN_PATTERN = /\b(insert|update|delete|drop|alter|create|pragma|attach|detach|replace|truncate)\b/i;
|
|
18
|
+
const SQL_REQUIRED_TABLE = /\bsite_events\b/i;
|
|
19
|
+
|
|
20
|
+
/** Adjusts end date to include the entire day (23:59:59.999 UTC) */
|
|
21
|
+
function getEndOfDay(date: Date): Date {
|
|
22
|
+
const endOfDay = new Date(date);
|
|
23
|
+
endOfDay.setUTCHours(23, 59, 59, 999);
|
|
24
|
+
return endOfDay;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Returns getEndOfDay(date) unless isExact is true, in which case returns date as-is. */
|
|
28
|
+
function resolveEndDate(date: Date, isExact?: boolean): Date {
|
|
29
|
+
return isExact ? date : getEndOfDay(date);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeTimeZone(timeZone?: string | null): string {
|
|
33
|
+
if (typeof timeZone !== "string") return "UTC";
|
|
34
|
+
const trimmed = timeZone.trim();
|
|
35
|
+
if (!trimmed) return "UTC";
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
Intl.DateTimeFormat(undefined, { timeZone: trimmed });
|
|
39
|
+
return trimmed;
|
|
40
|
+
} catch {
|
|
41
|
+
return "UTC";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createDateBucketFormatter(timeZone: string) {
|
|
46
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
47
|
+
timeZone,
|
|
48
|
+
year: "numeric",
|
|
49
|
+
month: "2-digit",
|
|
50
|
+
day: "2-digit",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return (date: Date): string => {
|
|
54
|
+
const parts = formatter.formatToParts(date);
|
|
55
|
+
const year = parts.find((part) => part.type === "year")?.value;
|
|
56
|
+
const month = parts.find((part) => part.type === "month")?.value;
|
|
57
|
+
const day = parts.find((part) => part.type === "day")?.value;
|
|
58
|
+
|
|
59
|
+
if (!year || !month || !day) {
|
|
60
|
+
return date.toISOString().slice(0, 10);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return `${year}-${month}-${day}`;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeSqlQuery(query: string) {
|
|
68
|
+
return query.trim().replace(/;\s*$/, "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateSqlQuery(query: string): string | null {
|
|
72
|
+
if (!query) return "Query is required";
|
|
73
|
+
if (query.includes(";")) return "Multiple statements are not allowed";
|
|
74
|
+
if (!SQL_ALLOWED_PREFIX.test(query)) return "Only SELECT queries are allowed";
|
|
75
|
+
if (SQL_FORBIDDEN_PATTERN.test(query)) return "Only read-only SELECT queries are allowed";
|
|
76
|
+
if (!SQL_REQUIRED_TABLE.test(query)) return "Query must reference site_events";
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Site-specific Durable Object for storing and querying site events
|
|
82
|
+
*
|
|
83
|
+
* Each instance represents a single site and contains all event data for that site.
|
|
84
|
+
* Naming convention: Site-{site_id} (e.g., Site-123)
|
|
85
|
+
*/
|
|
86
|
+
export class SiteDurableObject extends DurableObject {
|
|
87
|
+
private db: Durable_DB;
|
|
88
|
+
private state!: DurableObjectState;
|
|
89
|
+
private site_id: number | null = null;
|
|
90
|
+
private site_uuid: string | null = null;
|
|
91
|
+
|
|
92
|
+
constructor(state: DurableObjectState, env: any) {
|
|
93
|
+
super(state, env);
|
|
94
|
+
this.state = state;
|
|
95
|
+
// Use the SQL storage from the durable object state
|
|
96
|
+
// this.db = drizzle(state.storage);
|
|
97
|
+
this.db = drizzle(state.storage, {
|
|
98
|
+
schema,
|
|
99
|
+
//logger: true
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
state.blockConcurrencyWhile(async () => {
|
|
103
|
+
await this._migrate();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async setSiteInfo(site_id: number, site_uuid: string) {
|
|
109
|
+
this.site_id = site_id;
|
|
110
|
+
this.site_uuid = site_uuid;
|
|
111
|
+
await this.scheduleRidSaltAlarm();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async scheduleRidSaltAlarm() {
|
|
115
|
+
if (!this.site_id) return;
|
|
116
|
+
try {
|
|
117
|
+
const ridConfig = await getSiteRidConfig(this.site_id);
|
|
118
|
+
if (!ridConfig) return;
|
|
119
|
+
const rawExpire = ridConfig.rid_salt_expire ? new Date(ridConfig.rid_salt_expire) : null;
|
|
120
|
+
if (!rawExpire || Number.isNaN(rawExpire.getTime())) {
|
|
121
|
+
const rotated = await rotateSiteRidSalt(this.site_id);
|
|
122
|
+
if (rotated?.rid_salt_expire) {
|
|
123
|
+
await this.state.storage.setAlarm(rotated.rid_salt_expire);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (rawExpire <= new Date()) {
|
|
128
|
+
const rotated = await rotateSiteRidSalt(this.site_id);
|
|
129
|
+
if (rotated?.rid_salt_expire) {
|
|
130
|
+
await this.state.storage.setAlarm(rotated.rid_salt_expire);
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
await this.state.storage.setAlarm(rawExpire);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (this.env.ENVIRONMENT === "development") {
|
|
137
|
+
console.error(`SiteDurableObject scheduleRidSaltAlarm error for site ${this.site_id}:`, error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async alarm() {
|
|
143
|
+
if (!this.site_id) return;
|
|
144
|
+
try {
|
|
145
|
+
const ridConfig = await getSiteRidConfig(this.site_id);
|
|
146
|
+
if (!ridConfig) return;
|
|
147
|
+
const rawExpire = ridConfig.rid_salt_expire ? new Date(ridConfig.rid_salt_expire) : null;
|
|
148
|
+
if (!rawExpire || Number.isNaN(rawExpire.getTime()) || rawExpire <= new Date()) {
|
|
149
|
+
const rotated = await rotateSiteRidSalt(this.site_id);
|
|
150
|
+
if (rotated?.rid_salt_expire) {
|
|
151
|
+
await this.state.storage.setAlarm(rotated.rid_salt_expire);
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
await this.state.storage.setAlarm(rawExpire);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (this.env.ENVIRONMENT === "development") {
|
|
158
|
+
console.error(`SiteDurableObject alarm error for site ${this.site_id}:`, error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async _migrate() {
|
|
164
|
+
migrate(this.db, migrations);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async fetch(_request: Request): Promise<Response> {
|
|
168
|
+
// Fallback fetch method for compatibility
|
|
169
|
+
return new Response('Use RPC methods instead of fetch', { status: 501 });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Insert new events into the site's storage
|
|
174
|
+
*/
|
|
175
|
+
async insertEvents(events: SiteEventInput[]) {
|
|
176
|
+
try {
|
|
177
|
+
if (!this.site_id) {
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
inserted: 0,
|
|
181
|
+
siteId: this.site_id,
|
|
182
|
+
error: "Site not initialized",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (events.length === 0) {
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
inserted: 0,
|
|
190
|
+
siteId: this.site_id,
|
|
191
|
+
error: "No events provided",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Transform input events to database format
|
|
196
|
+
const dbEvents: SiteEventInsert[] = events.map((event) => ({
|
|
197
|
+
bot_data: event.bot_data as Record<string, string> | null,
|
|
198
|
+
browser: event.browser || null,
|
|
199
|
+
city: event.city || null,
|
|
200
|
+
client_page_url: event.client_page_url || null,
|
|
201
|
+
country: event.country || null,
|
|
202
|
+
custom_data: event.custom_data as Record<string, string> | null,
|
|
203
|
+
device_type: event.device_type || null,
|
|
204
|
+
event: event.event,
|
|
205
|
+
operating_system: event.operating_system || null,
|
|
206
|
+
page_url: event.page_url || null,
|
|
207
|
+
postal: event.postal || null,
|
|
208
|
+
query_params: event.query_params as Record<string, string> | null,
|
|
209
|
+
referer: event.referer || null,
|
|
210
|
+
region: event.region || null,
|
|
211
|
+
rid: event.rid || null,
|
|
212
|
+
screen_height: event.screen_height || null,
|
|
213
|
+
screen_width: event.screen_width || null,
|
|
214
|
+
site_id: this.site_id as number, // Required by the durable schema
|
|
215
|
+
tag_id: event.tag_id,
|
|
216
|
+
createdAt: event.createdAt || new Date(),
|
|
217
|
+
updatedAt: event.updatedAt || new Date(),
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
// Durable Object SQLite has a 100 bound parameters limit per query
|
|
221
|
+
// Each event has ~21 fields, so max 4 events per batch (4 × 21 = 84 < 100)
|
|
222
|
+
const BATCH_SIZE = 4;
|
|
223
|
+
let totalInserted = 0;
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < dbEvents.length; i += BATCH_SIZE) {
|
|
226
|
+
const batch = dbEvents.slice(i, i + BATCH_SIZE);
|
|
227
|
+
await this.db.insert(siteEvents).values(batch);
|
|
228
|
+
totalInserted += batch.length;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
success: true,
|
|
233
|
+
inserted: totalInserted,
|
|
234
|
+
siteId: this.site_id
|
|
235
|
+
};
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (this.env.ENVIRONMENT === "development") console.error(`SiteDurableObject insertEvents error for site ${this.site_id}:`, error);
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
inserted: 0,
|
|
241
|
+
siteId: this.site_id,
|
|
242
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async getStuff(): Promise<{ error: boolean, events: Array<{}> }> {
|
|
249
|
+
const query = this.db
|
|
250
|
+
.select()
|
|
251
|
+
.from(siteEvents)
|
|
252
|
+
const events = await query;
|
|
253
|
+
return { error: false, events: events }
|
|
254
|
+
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Query events with filtering and pagination
|
|
258
|
+
*/
|
|
259
|
+
async getEventsData(options: GetEventsOptions = {}) {
|
|
260
|
+
if (this.env.ENVIRONMENT === "development") console.log('GRABBING EVENTS DATA FROM DURABLE getEventsData', options)
|
|
261
|
+
const { error, events, pagination, totalAllTime } = await getEvents(this.db, options);
|
|
262
|
+
return { error, events, pagination, totalAllTime }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Get aggregated statistics for dashboard
|
|
268
|
+
*/
|
|
269
|
+
async getStats(options: {
|
|
270
|
+
startDate?: Date;
|
|
271
|
+
endDate?: Date;
|
|
272
|
+
endDateIsExact?: boolean;
|
|
273
|
+
} = {}) {
|
|
274
|
+
try {
|
|
275
|
+
const { startDate, endDate, endDateIsExact } = options;
|
|
276
|
+
|
|
277
|
+
// Build date filter conditions
|
|
278
|
+
const conditions = [];
|
|
279
|
+
if (startDate) {
|
|
280
|
+
conditions.push(gte(siteEvents.createdAt, startDate));
|
|
281
|
+
}
|
|
282
|
+
if (endDate) {
|
|
283
|
+
conditions.push(lte(siteEvents.createdAt, resolveEndDate(endDate, endDateIsExact)));
|
|
284
|
+
}
|
|
285
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
286
|
+
|
|
287
|
+
// Get total events count
|
|
288
|
+
const totalEventsResult = await this.db
|
|
289
|
+
.select({ count: count() })
|
|
290
|
+
.from(siteEvents)
|
|
291
|
+
.where(whereClause);
|
|
292
|
+
|
|
293
|
+
const totalEvents = totalEventsResult[0]?.count || 0;
|
|
294
|
+
|
|
295
|
+
// Get events by type
|
|
296
|
+
const eventsByTypeResult = await this.db
|
|
297
|
+
.select({
|
|
298
|
+
event: siteEvents.event,
|
|
299
|
+
count: count()
|
|
300
|
+
})
|
|
301
|
+
.from(siteEvents)
|
|
302
|
+
.where(whereClause)
|
|
303
|
+
.groupBy(siteEvents.event)
|
|
304
|
+
.orderBy(desc(count()))
|
|
305
|
+
.limit(10);
|
|
306
|
+
|
|
307
|
+
// Get events by country
|
|
308
|
+
const eventsByCountryResult = await this.db
|
|
309
|
+
.select({
|
|
310
|
+
country: siteEvents.country,
|
|
311
|
+
count: count()
|
|
312
|
+
})
|
|
313
|
+
.from(siteEvents)
|
|
314
|
+
.where(whereClause)
|
|
315
|
+
.groupBy(siteEvents.country)
|
|
316
|
+
.orderBy(desc(count()))
|
|
317
|
+
.limit(10);
|
|
318
|
+
|
|
319
|
+
// Get events by device
|
|
320
|
+
const eventsByDeviceResult = await this.db
|
|
321
|
+
.select({
|
|
322
|
+
device_type: siteEvents.device_type,
|
|
323
|
+
count: count()
|
|
324
|
+
})
|
|
325
|
+
.from(siteEvents)
|
|
326
|
+
.where(whereClause)
|
|
327
|
+
.groupBy(siteEvents.device_type)
|
|
328
|
+
.orderBy(desc(count()))
|
|
329
|
+
.limit(10);
|
|
330
|
+
|
|
331
|
+
// Get top referers
|
|
332
|
+
const topReferersResult = await this.db
|
|
333
|
+
.select({
|
|
334
|
+
referer: siteEvents.referer,
|
|
335
|
+
count: count()
|
|
336
|
+
})
|
|
337
|
+
.from(siteEvents)
|
|
338
|
+
.where(whereClause)
|
|
339
|
+
.groupBy(siteEvents.referer)
|
|
340
|
+
.orderBy(desc(count()))
|
|
341
|
+
.limit(10);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
totalEvents,
|
|
345
|
+
eventsByType: eventsByTypeResult,
|
|
346
|
+
eventsByCountry: eventsByCountryResult,
|
|
347
|
+
eventsByDevice: eventsByDeviceResult,
|
|
348
|
+
topReferers: topReferersResult,
|
|
349
|
+
siteId: this.site_id,
|
|
350
|
+
dateRange: {
|
|
351
|
+
start: startDate?.toISOString(),
|
|
352
|
+
end: endDate?.toISOString()
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (this.env.ENVIRONMENT === "development") console.error(`SiteDurableObject getStats error for site ${this.site_id}:`, error);
|
|
357
|
+
return {
|
|
358
|
+
totalEvents: 0,
|
|
359
|
+
eventsByType: [],
|
|
360
|
+
eventsByCountry: [],
|
|
361
|
+
eventsByDevice: [],
|
|
362
|
+
topReferers: [],
|
|
363
|
+
siteId: this.site_id,
|
|
364
|
+
dateRange: {
|
|
365
|
+
start: options.startDate?.toISOString(),
|
|
366
|
+
end: options.endDate?.toISOString()
|
|
367
|
+
},
|
|
368
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async getDashboardAggregates(options: {
|
|
374
|
+
startDate?: Date;
|
|
375
|
+
endDate?: Date;
|
|
376
|
+
endDateIsExact?: boolean;
|
|
377
|
+
timezone?: string | null;
|
|
378
|
+
country?: string;
|
|
379
|
+
deviceType?: string;
|
|
380
|
+
source?: string;
|
|
381
|
+
pageUrl?: string;
|
|
382
|
+
city?: string;
|
|
383
|
+
region?: string;
|
|
384
|
+
event?: string;
|
|
385
|
+
} = {}) {
|
|
386
|
+
try {
|
|
387
|
+
const { startDate, endDate, endDateIsExact, timezone, country, deviceType, source, pageUrl, city, region, event } = options;
|
|
388
|
+
const effectiveTimeZone = normalizeTimeZone(timezone);
|
|
389
|
+
|
|
390
|
+
const conditions = [];
|
|
391
|
+
if (startDate) {
|
|
392
|
+
conditions.push(gte(siteEvents.createdAt, startDate));
|
|
393
|
+
}
|
|
394
|
+
if (endDate) {
|
|
395
|
+
conditions.push(lte(siteEvents.createdAt, resolveEndDate(endDate, endDateIsExact)));
|
|
396
|
+
}
|
|
397
|
+
if (country) {
|
|
398
|
+
conditions.push(eq(siteEvents.country, country));
|
|
399
|
+
}
|
|
400
|
+
if (deviceType) {
|
|
401
|
+
conditions.push(eq(siteEvents.device_type, deviceType));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const normalizedSource = source?.trim().toLowerCase() ?? "";
|
|
405
|
+
if (normalizedSource.length > 0) {
|
|
406
|
+
if (normalizedSource === "direct") {
|
|
407
|
+
conditions.push(
|
|
408
|
+
or(
|
|
409
|
+
isNull(siteEvents.referer),
|
|
410
|
+
eq(siteEvents.referer, ""),
|
|
411
|
+
eq(siteEvents.referer, "null"),
|
|
412
|
+
),
|
|
413
|
+
);
|
|
414
|
+
} else {
|
|
415
|
+
conditions.push(like(siteEvents.referer, `%${normalizedSource}%`));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (pageUrl) {
|
|
420
|
+
conditions.push(eq(siteEvents.client_page_url, pageUrl));
|
|
421
|
+
}
|
|
422
|
+
if (city) {
|
|
423
|
+
conditions.push(eq(siteEvents.city, city));
|
|
424
|
+
}
|
|
425
|
+
if (region) {
|
|
426
|
+
conditions.push(eq(siteEvents.region, region));
|
|
427
|
+
}
|
|
428
|
+
if (event) {
|
|
429
|
+
conditions.push(eq(siteEvents.event, event));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
433
|
+
const pageViewWhereClause = and(...conditions, eq(siteEvents.event, "page_view"));
|
|
434
|
+
const sessionsWhereClause = and(
|
|
435
|
+
...conditions,
|
|
436
|
+
isNotNull(siteEvents.rid),
|
|
437
|
+
ne(siteEvents.rid, ""),
|
|
438
|
+
);
|
|
439
|
+
const conversionWhereClause = and(
|
|
440
|
+
...conditions,
|
|
441
|
+
or(eq(siteEvents.event, "conversion"), eq(siteEvents.event, "purchase")),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const totalEventsResult = await this.db
|
|
445
|
+
.select({ count: count() })
|
|
446
|
+
.from(siteEvents)
|
|
447
|
+
.where(whereClause);
|
|
448
|
+
|
|
449
|
+
const totalAllTimeResult = await this.db
|
|
450
|
+
.select({ count: count() })
|
|
451
|
+
.from(siteEvents);
|
|
452
|
+
|
|
453
|
+
const uniqueVisitorsResult = await this.db
|
|
454
|
+
.select({ count: sql<number>`COUNT(DISTINCT ${siteEvents.rid})` })
|
|
455
|
+
.from(siteEvents)
|
|
456
|
+
.where(sessionsWhereClause);
|
|
457
|
+
|
|
458
|
+
const totalPageViewsResult = await this.db
|
|
459
|
+
.select({ count: count() })
|
|
460
|
+
.from(siteEvents)
|
|
461
|
+
.where(pageViewWhereClause);
|
|
462
|
+
|
|
463
|
+
const conversionEventsResult = await this.db
|
|
464
|
+
.select({ count: count() })
|
|
465
|
+
.from(siteEvents)
|
|
466
|
+
.where(conversionWhereClause);
|
|
467
|
+
|
|
468
|
+
const pageViewsByRidResult = await this.db
|
|
469
|
+
.select({
|
|
470
|
+
rid: siteEvents.rid,
|
|
471
|
+
pageViewCount: count(),
|
|
472
|
+
})
|
|
473
|
+
.from(siteEvents)
|
|
474
|
+
.where(and(...conditions, eq(siteEvents.event, "page_view"), isNotNull(siteEvents.rid), ne(siteEvents.rid, "")))
|
|
475
|
+
.groupBy(siteEvents.rid);
|
|
476
|
+
|
|
477
|
+
const sessionDurationRows = await this.db
|
|
478
|
+
.select({
|
|
479
|
+
rid: siteEvents.rid,
|
|
480
|
+
firstSeen: sql<number>`min(${siteEvents.createdAt}) * 1000`,
|
|
481
|
+
lastSeen: sql<number>`max(${siteEvents.createdAt}) * 1000`,
|
|
482
|
+
})
|
|
483
|
+
.from(siteEvents)
|
|
484
|
+
.where(sessionsWhereClause)
|
|
485
|
+
.groupBy(siteEvents.rid);
|
|
486
|
+
|
|
487
|
+
const pageViewHourExpr = sql<number>`CAST(${siteEvents.createdAt} / 3600 AS INTEGER) * 3600`;
|
|
488
|
+
|
|
489
|
+
const pageViewsByHourResult = await this.db
|
|
490
|
+
.select({
|
|
491
|
+
hourEpoch: pageViewHourExpr,
|
|
492
|
+
count: count(),
|
|
493
|
+
})
|
|
494
|
+
.from(siteEvents)
|
|
495
|
+
.where(pageViewWhereClause)
|
|
496
|
+
.groupBy(pageViewHourExpr)
|
|
497
|
+
.orderBy(pageViewHourExpr);
|
|
498
|
+
|
|
499
|
+
const formatDateBucket = createDateBucketFormatter(effectiveTimeZone);
|
|
500
|
+
const pageViewsByDate = new Map<string, number>();
|
|
501
|
+
|
|
502
|
+
for (const item of pageViewsByHourResult) {
|
|
503
|
+
const hourEpoch = Number(item.hourEpoch);
|
|
504
|
+
if (!Number.isFinite(hourEpoch)) continue;
|
|
505
|
+
const bucketDate = formatDateBucket(new Date(hourEpoch * 1000));
|
|
506
|
+
pageViewsByDate.set(bucketDate, (pageViewsByDate.get(bucketDate) ?? 0) + item.count);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const pageViews = Array.from(pageViewsByDate.entries())
|
|
510
|
+
.toSorted((a, b) => a[0].localeCompare(b[0]))
|
|
511
|
+
.map(([x, y]) => ({ x, y }));
|
|
512
|
+
|
|
513
|
+
const eventsByTypeResult = await this.db
|
|
514
|
+
.select({
|
|
515
|
+
event: siteEvents.event,
|
|
516
|
+
count: count(),
|
|
517
|
+
})
|
|
518
|
+
.from(siteEvents)
|
|
519
|
+
.where(whereClause)
|
|
520
|
+
.groupBy(siteEvents.event)
|
|
521
|
+
.orderBy(desc(count()))
|
|
522
|
+
.limit(100);
|
|
523
|
+
|
|
524
|
+
const devicesResult = await this.db
|
|
525
|
+
.select({
|
|
526
|
+
deviceType: siteEvents.device_type,
|
|
527
|
+
count: count(),
|
|
528
|
+
})
|
|
529
|
+
.from(siteEvents)
|
|
530
|
+
.where(pageViewWhereClause)
|
|
531
|
+
.groupBy(siteEvents.device_type)
|
|
532
|
+
.orderBy(desc(count()))
|
|
533
|
+
.limit(25);
|
|
534
|
+
|
|
535
|
+
const browsersResult = await this.db
|
|
536
|
+
.select({
|
|
537
|
+
browser: siteEvents.browser,
|
|
538
|
+
count: count(),
|
|
539
|
+
})
|
|
540
|
+
.from(siteEvents)
|
|
541
|
+
.where(pageViewWhereClause)
|
|
542
|
+
.groupBy(siteEvents.browser)
|
|
543
|
+
.orderBy(desc(count()))
|
|
544
|
+
.limit(25);
|
|
545
|
+
|
|
546
|
+
const operatingSystemsResult = await this.db
|
|
547
|
+
.select({
|
|
548
|
+
os: siteEvents.operating_system,
|
|
549
|
+
count: count(),
|
|
550
|
+
})
|
|
551
|
+
.from(siteEvents)
|
|
552
|
+
.where(pageViewWhereClause)
|
|
553
|
+
.groupBy(siteEvents.operating_system)
|
|
554
|
+
.orderBy(desc(count()))
|
|
555
|
+
.limit(25);
|
|
556
|
+
|
|
557
|
+
const referersResult = await this.db
|
|
558
|
+
.select({
|
|
559
|
+
referer: siteEvents.referer,
|
|
560
|
+
count: count(),
|
|
561
|
+
})
|
|
562
|
+
.from(siteEvents)
|
|
563
|
+
.where(pageViewWhereClause)
|
|
564
|
+
.groupBy(siteEvents.referer)
|
|
565
|
+
.orderBy(desc(count()))
|
|
566
|
+
.limit(100);
|
|
567
|
+
|
|
568
|
+
const topPagesResult = await this.db
|
|
569
|
+
.select({
|
|
570
|
+
page: siteEvents.client_page_url,
|
|
571
|
+
count: count(),
|
|
572
|
+
})
|
|
573
|
+
.from(siteEvents)
|
|
574
|
+
.where(pageViewWhereClause)
|
|
575
|
+
.groupBy(siteEvents.client_page_url)
|
|
576
|
+
.orderBy(desc(count()))
|
|
577
|
+
.limit(100);
|
|
578
|
+
|
|
579
|
+
const citiesResult = await this.db
|
|
580
|
+
.select({
|
|
581
|
+
city: siteEvents.city,
|
|
582
|
+
country: siteEvents.country,
|
|
583
|
+
count: count(),
|
|
584
|
+
})
|
|
585
|
+
.from(siteEvents)
|
|
586
|
+
.where(pageViewWhereClause)
|
|
587
|
+
.groupBy(siteEvents.city, siteEvents.country)
|
|
588
|
+
.orderBy(desc(count()))
|
|
589
|
+
.limit(100);
|
|
590
|
+
|
|
591
|
+
const countriesResult = await this.db
|
|
592
|
+
.select({
|
|
593
|
+
country: siteEvents.country,
|
|
594
|
+
count: count(),
|
|
595
|
+
})
|
|
596
|
+
.from(siteEvents)
|
|
597
|
+
.where(pageViewWhereClause)
|
|
598
|
+
.groupBy(siteEvents.country)
|
|
599
|
+
.orderBy(desc(count()))
|
|
600
|
+
.limit(250);
|
|
601
|
+
|
|
602
|
+
const countryUniquesResult = await this.db
|
|
603
|
+
.select({
|
|
604
|
+
country: siteEvents.country,
|
|
605
|
+
count: sql<number>`COUNT(DISTINCT ${siteEvents.rid})`,
|
|
606
|
+
})
|
|
607
|
+
.from(siteEvents)
|
|
608
|
+
.where(and(pageViewWhereClause, isNotNull(siteEvents.rid), ne(siteEvents.rid, "")))
|
|
609
|
+
.groupBy(siteEvents.country)
|
|
610
|
+
.orderBy(desc(sql<number>`COUNT(DISTINCT ${siteEvents.rid})`))
|
|
611
|
+
.limit(250);
|
|
612
|
+
|
|
613
|
+
const regionsResult = await this.db
|
|
614
|
+
.select({
|
|
615
|
+
region: siteEvents.region,
|
|
616
|
+
count: count(),
|
|
617
|
+
})
|
|
618
|
+
.from(siteEvents)
|
|
619
|
+
.where(pageViewWhereClause)
|
|
620
|
+
.groupBy(siteEvents.region)
|
|
621
|
+
.orderBy(desc(count()))
|
|
622
|
+
.limit(100);
|
|
623
|
+
|
|
624
|
+
const totalEvents = totalEventsResult[0]?.count || 0;
|
|
625
|
+
const totalAllTime = totalAllTimeResult[0]?.count || 0;
|
|
626
|
+
const uniqueVisitors = uniqueVisitorsResult[0]?.count || 0;
|
|
627
|
+
const totalPageViews = totalPageViewsResult[0]?.count || 0;
|
|
628
|
+
const nonPageViewEvents = Math.max(0, totalEvents - totalPageViews);
|
|
629
|
+
const conversionEvents = conversionEventsResult[0]?.count || 0;
|
|
630
|
+
|
|
631
|
+
const singlePageSessions = pageViewsByRidResult.filter((row) => row.pageViewCount === 1).length;
|
|
632
|
+
const bounceRatePercent = uniqueVisitors > 0
|
|
633
|
+
? Number(((singlePageSessions / uniqueVisitors) * 100).toFixed(1))
|
|
634
|
+
: 0;
|
|
635
|
+
const conversionRatePercent = uniqueVisitors > 0
|
|
636
|
+
? Number(((conversionEvents / uniqueVisitors) * 100).toFixed(2))
|
|
637
|
+
: 0;
|
|
638
|
+
|
|
639
|
+
const totalDurationSeconds = sessionDurationRows.reduce((acc, row) => {
|
|
640
|
+
const firstSeen = row.firstSeen ?? 0;
|
|
641
|
+
const lastSeen = row.lastSeen ?? 0;
|
|
642
|
+
if (!firstSeen || !lastSeen) return acc;
|
|
643
|
+
const duration = Math.max(0, (lastSeen - firstSeen) / 1000);
|
|
644
|
+
return acc + duration;
|
|
645
|
+
}, 0);
|
|
646
|
+
const avgSessionDurationSeconds = sessionDurationRows.length > 0
|
|
647
|
+
? totalDurationSeconds / sessionDurationRows.length
|
|
648
|
+
: 0;
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
scoreCards: {
|
|
652
|
+
uniqueVisitors,
|
|
653
|
+
totalPageViews,
|
|
654
|
+
nonPageViewEvents,
|
|
655
|
+
bounceRatePercent,
|
|
656
|
+
conversionRatePercent,
|
|
657
|
+
avgSessionDurationSeconds,
|
|
658
|
+
},
|
|
659
|
+
pageViews,
|
|
660
|
+
events: eventsByTypeResult.map((item) => [item.event ?? "Unknown", item.count] as [string, number]),
|
|
661
|
+
devices: devicesResult.map((item) => [item.deviceType ?? "Unknown", item.count] as [string, number]),
|
|
662
|
+
cities: citiesResult.map((item) => [
|
|
663
|
+
item.city ?? "Unknown",
|
|
664
|
+
{
|
|
665
|
+
count: item.count,
|
|
666
|
+
country: item.country ?? "Unknown",
|
|
667
|
+
},
|
|
668
|
+
] as [string, { count: number; country: string }]),
|
|
669
|
+
countries: countriesResult
|
|
670
|
+
.filter((item) => !!item.country)
|
|
671
|
+
.map((item) => ({
|
|
672
|
+
id: item.country!,
|
|
673
|
+
value: item.count,
|
|
674
|
+
})),
|
|
675
|
+
countryUniques: countryUniquesResult
|
|
676
|
+
.filter((item) => !!item.country)
|
|
677
|
+
.map((item) => ({
|
|
678
|
+
id: item.country!,
|
|
679
|
+
value: item.count,
|
|
680
|
+
})),
|
|
681
|
+
regions: regionsResult
|
|
682
|
+
.filter((item) => !!item.region)
|
|
683
|
+
.map((item) => ({
|
|
684
|
+
id: item.region!,
|
|
685
|
+
value: item.count,
|
|
686
|
+
})),
|
|
687
|
+
referers: referersResult.map((item) => ({
|
|
688
|
+
id: item.referer ?? "Direct",
|
|
689
|
+
value: item.count,
|
|
690
|
+
})),
|
|
691
|
+
topPages: topPagesResult.map((item) => ({
|
|
692
|
+
id: item.page ?? "Unknown",
|
|
693
|
+
value: item.count,
|
|
694
|
+
})),
|
|
695
|
+
browsers: browsersResult.map((item) => ({
|
|
696
|
+
id: item.browser ?? "Unknown",
|
|
697
|
+
value: item.count,
|
|
698
|
+
})),
|
|
699
|
+
operatingSystems: operatingSystemsResult
|
|
700
|
+
.filter((item) => !!item.os)
|
|
701
|
+
.map((item) => ({
|
|
702
|
+
id: item.os ?? "Unknown",
|
|
703
|
+
value: item.count,
|
|
704
|
+
})),
|
|
705
|
+
pagination: {
|
|
706
|
+
limit: 0,
|
|
707
|
+
offset: 0,
|
|
708
|
+
total: totalEvents,
|
|
709
|
+
hasMore: false,
|
|
710
|
+
},
|
|
711
|
+
totalEvents,
|
|
712
|
+
totalAllTime,
|
|
713
|
+
siteId: this.site_id,
|
|
714
|
+
dateRange: {
|
|
715
|
+
start: startDate?.toISOString(),
|
|
716
|
+
end: endDate?.toISOString(),
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (this.env.ENVIRONMENT === "development") {
|
|
721
|
+
console.error(`SiteDurableObject getDashboardAggregates error for site ${this.site_id}:`, error);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
scoreCards: {
|
|
726
|
+
uniqueVisitors: 0,
|
|
727
|
+
totalPageViews: 0,
|
|
728
|
+
nonPageViewEvents: 0,
|
|
729
|
+
bounceRatePercent: 0,
|
|
730
|
+
conversionRatePercent: 0,
|
|
731
|
+
avgSessionDurationSeconds: 0,
|
|
732
|
+
},
|
|
733
|
+
pageViews: [],
|
|
734
|
+
events: [],
|
|
735
|
+
devices: [],
|
|
736
|
+
cities: [],
|
|
737
|
+
countries: [],
|
|
738
|
+
countryUniques: [],
|
|
739
|
+
referers: [],
|
|
740
|
+
topPages: [],
|
|
741
|
+
browsers: [],
|
|
742
|
+
operatingSystems: [],
|
|
743
|
+
pagination: {
|
|
744
|
+
limit: 0,
|
|
745
|
+
offset: 0,
|
|
746
|
+
total: 0,
|
|
747
|
+
hasMore: false,
|
|
748
|
+
},
|
|
749
|
+
totalEvents: 0,
|
|
750
|
+
totalAllTime: 0,
|
|
751
|
+
siteId: this.site_id,
|
|
752
|
+
dateRange: {
|
|
753
|
+
start: options.startDate?.toISOString(),
|
|
754
|
+
end: options.endDate?.toISOString(),
|
|
755
|
+
},
|
|
756
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Summarize events by name
|
|
763
|
+
*/
|
|
764
|
+
async getEventSummary(options: {
|
|
765
|
+
startDate?: Date;
|
|
766
|
+
endDate?: Date;
|
|
767
|
+
endDateIsExact?: boolean;
|
|
768
|
+
limit?: number;
|
|
769
|
+
offset?: number;
|
|
770
|
+
search?: string;
|
|
771
|
+
type?: "all" | "autocapture" | "event_capture" | "page_view";
|
|
772
|
+
action?: "all" | "click" | "submit" | "change" | "rule";
|
|
773
|
+
sortBy?: "count" | "first_seen" | "last_seen";
|
|
774
|
+
sortDirection?: "asc" | "desc";
|
|
775
|
+
} = {}) {
|
|
776
|
+
try {
|
|
777
|
+
const { startDate, endDate, endDateIsExact } = options;
|
|
778
|
+
|
|
779
|
+
const conditions = [];
|
|
780
|
+
if (startDate) {
|
|
781
|
+
conditions.push(gte(siteEvents.createdAt, startDate));
|
|
782
|
+
}
|
|
783
|
+
if (endDate) {
|
|
784
|
+
conditions.push(lte(siteEvents.createdAt, resolveEndDate(endDate, endDateIsExact)));
|
|
785
|
+
}
|
|
786
|
+
if (options.search) {
|
|
787
|
+
const trimmedSearch = options.search.trim();
|
|
788
|
+
if (trimmedSearch.length > 0) {
|
|
789
|
+
conditions.push(like(siteEvents.event, `%${trimmedSearch}%`));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (options.type === "autocapture") {
|
|
794
|
+
conditions.push(or(like(siteEvents.event, "$ac_%"), eq(siteEvents.event, "auto_capture")));
|
|
795
|
+
} else if (options.type === "event_capture") {
|
|
796
|
+
conditions.push(
|
|
797
|
+
and(
|
|
798
|
+
isNotNull(siteEvents.event),
|
|
799
|
+
ne(siteEvents.event, "page_view"),
|
|
800
|
+
ne(siteEvents.event, "auto_capture"),
|
|
801
|
+
not(like(siteEvents.event, "$ac_%")),
|
|
802
|
+
),
|
|
803
|
+
);
|
|
804
|
+
} else if (options.type === "page_view") {
|
|
805
|
+
conditions.push(eq(siteEvents.event, "page_view"));
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (options.action === "rule") {
|
|
809
|
+
conditions.push(eq(siteEvents.event, "auto_capture"));
|
|
810
|
+
} else if (options.action === "submit") {
|
|
811
|
+
conditions.push(like(siteEvents.event, "$ac_form_%"));
|
|
812
|
+
} else if (options.action === "change") {
|
|
813
|
+
conditions.push(like(siteEvents.event, "$ac_input_%"));
|
|
814
|
+
} else if (options.action === "click") {
|
|
815
|
+
conditions.push(
|
|
816
|
+
and(
|
|
817
|
+
like(siteEvents.event, "$ac_%"),
|
|
818
|
+
not(like(siteEvents.event, "$ac_form_%")),
|
|
819
|
+
not(like(siteEvents.event, "$ac_input_%")),
|
|
820
|
+
),
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
825
|
+
|
|
826
|
+
const limit = Math.min(Math.max(1, options.limit ?? 50), 500);
|
|
827
|
+
const offset = Math.max(0, options.offset ?? 0);
|
|
828
|
+
const sortBy = options.sortBy ?? "count";
|
|
829
|
+
const sortDirection = options.sortDirection === "asc" ? "asc" : "desc";
|
|
830
|
+
|
|
831
|
+
const countExpression = count();
|
|
832
|
+
const firstSeenExpression = sql<number>`min(${siteEvents.createdAt}) * 1000`;
|
|
833
|
+
const lastSeenExpression = sql<number>`max(${siteEvents.createdAt}) * 1000`;
|
|
834
|
+
|
|
835
|
+
const sortExpression =
|
|
836
|
+
sortBy === "first_seen"
|
|
837
|
+
? firstSeenExpression
|
|
838
|
+
: sortBy === "last_seen"
|
|
839
|
+
? lastSeenExpression
|
|
840
|
+
: countExpression;
|
|
841
|
+
const primarySort = sortDirection === "asc" ? asc(sortExpression) : desc(sortExpression);
|
|
842
|
+
const secondarySort = sortBy === "count" ? desc(lastSeenExpression) : desc(countExpression);
|
|
843
|
+
|
|
844
|
+
const summary = await this.db
|
|
845
|
+
.select({
|
|
846
|
+
event: siteEvents.event,
|
|
847
|
+
count: countExpression,
|
|
848
|
+
// Multiply by 1000 to convert Unix seconds to milliseconds for JavaScript Date
|
|
849
|
+
firstSeen: firstSeenExpression,
|
|
850
|
+
lastSeen: lastSeenExpression,
|
|
851
|
+
})
|
|
852
|
+
.from(siteEvents)
|
|
853
|
+
.where(whereClause)
|
|
854
|
+
.groupBy(siteEvents.event)
|
|
855
|
+
.orderBy(primarySort, secondarySort, asc(siteEvents.event))
|
|
856
|
+
.limit(limit)
|
|
857
|
+
.offset(offset);
|
|
858
|
+
|
|
859
|
+
const totalEventsResult = await this.db
|
|
860
|
+
.select({ count: count() })
|
|
861
|
+
.from(siteEvents)
|
|
862
|
+
.where(whereClause);
|
|
863
|
+
|
|
864
|
+
const totalEventTypesResult = await this.db
|
|
865
|
+
.select({ count: sql<number>`COUNT(DISTINCT ${siteEvents.event})` })
|
|
866
|
+
.from(siteEvents)
|
|
867
|
+
.where(whereClause);
|
|
868
|
+
|
|
869
|
+
const totalEvents = totalEventsResult[0]?.count || 0;
|
|
870
|
+
const totalEventTypes = totalEventTypesResult[0]?.count || 0;
|
|
871
|
+
|
|
872
|
+
return {
|
|
873
|
+
summary,
|
|
874
|
+
pagination: {
|
|
875
|
+
offset,
|
|
876
|
+
limit,
|
|
877
|
+
total: totalEventTypes,
|
|
878
|
+
hasMore: offset + limit < totalEventTypes,
|
|
879
|
+
},
|
|
880
|
+
totalEvents,
|
|
881
|
+
totalEventTypes,
|
|
882
|
+
siteId: this.site_id,
|
|
883
|
+
dateRange: {
|
|
884
|
+
start: startDate?.toISOString(),
|
|
885
|
+
end: endDate?.toISOString(),
|
|
886
|
+
},
|
|
887
|
+
};
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (this.env.ENVIRONMENT === "development") console.error(`SiteDurableObject getEventSummary error for site ${this.site_id}:`, error);
|
|
890
|
+
return {
|
|
891
|
+
summary: [],
|
|
892
|
+
pagination: {
|
|
893
|
+
offset: options.offset ?? 0,
|
|
894
|
+
limit: options.limit ?? 50,
|
|
895
|
+
total: 0,
|
|
896
|
+
hasMore: false,
|
|
897
|
+
},
|
|
898
|
+
totalEvents: 0,
|
|
899
|
+
totalEventTypes: 0,
|
|
900
|
+
siteId: this.site_id,
|
|
901
|
+
dateRange: {
|
|
902
|
+
start: options.startDate?.toISOString(),
|
|
903
|
+
end: options.endDate?.toISOString(),
|
|
904
|
+
},
|
|
905
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Get time series data for line charts
|
|
912
|
+
*/
|
|
913
|
+
async getTimeSeries(options: {
|
|
914
|
+
startDate?: Date;
|
|
915
|
+
endDate?: Date;
|
|
916
|
+
endDateIsExact?: boolean;
|
|
917
|
+
granularity?: 'hour' | 'day' | 'week' | 'month';
|
|
918
|
+
byEvent?: boolean;
|
|
919
|
+
} = {}) {
|
|
920
|
+
try {
|
|
921
|
+
const { startDate, endDate, endDateIsExact, granularity = 'day', byEvent = false } = options;
|
|
922
|
+
|
|
923
|
+
// Build date filter conditions
|
|
924
|
+
const conditions = [];
|
|
925
|
+
if (startDate) {
|
|
926
|
+
conditions.push(gte(siteEvents.createdAt, startDate));
|
|
927
|
+
}
|
|
928
|
+
if (endDate) {
|
|
929
|
+
conditions.push(lte(siteEvents.createdAt, resolveEndDate(endDate, endDateIsExact)));
|
|
930
|
+
}
|
|
931
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
932
|
+
|
|
933
|
+
// Build time series query based on granularity
|
|
934
|
+
let dateFormat: string;
|
|
935
|
+
switch (granularity) {
|
|
936
|
+
case 'hour':
|
|
937
|
+
dateFormat = '%Y-%m-%d %H:00:00';
|
|
938
|
+
break;
|
|
939
|
+
case 'week':
|
|
940
|
+
dateFormat = '%Y-W%W';
|
|
941
|
+
break;
|
|
942
|
+
case 'month':
|
|
943
|
+
dateFormat = '%Y-%m';
|
|
944
|
+
break;
|
|
945
|
+
default:
|
|
946
|
+
dateFormat = '%Y-%m-%d';
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const timeBucketExpr = sql<string>`strftime(${dateFormat}, ${siteEvents.createdAt}, 'unixepoch')`;
|
|
950
|
+
|
|
951
|
+
let query;
|
|
952
|
+
if (byEvent) {
|
|
953
|
+
query = this.db
|
|
954
|
+
.select({
|
|
955
|
+
date: timeBucketExpr.as('date'),
|
|
956
|
+
event: siteEvents.event,
|
|
957
|
+
count: count()
|
|
958
|
+
})
|
|
959
|
+
.from(siteEvents)
|
|
960
|
+
.where(whereClause)
|
|
961
|
+
.groupBy(timeBucketExpr, siteEvents.event)
|
|
962
|
+
.orderBy(timeBucketExpr);
|
|
963
|
+
} else {
|
|
964
|
+
query = this.db
|
|
965
|
+
.select({
|
|
966
|
+
date: timeBucketExpr.as('date'),
|
|
967
|
+
count: count()
|
|
968
|
+
})
|
|
969
|
+
.from(siteEvents)
|
|
970
|
+
.where(whereClause)
|
|
971
|
+
.groupBy(timeBucketExpr)
|
|
972
|
+
.orderBy(timeBucketExpr);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const data = await query;
|
|
976
|
+
|
|
977
|
+
return {
|
|
978
|
+
data,
|
|
979
|
+
granularity,
|
|
980
|
+
byEvent,
|
|
981
|
+
siteId: this.site_id,
|
|
982
|
+
dateRange: {
|
|
983
|
+
start: startDate?.toISOString(),
|
|
984
|
+
end: endDate?.toISOString()
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
} catch (error) {
|
|
988
|
+
if (this.env.ENVIRONMENT === "development") console.error(`SiteDurableObject getTimeSeries error for site ${this.site_id}:`, error);
|
|
989
|
+
return {
|
|
990
|
+
data: [],
|
|
991
|
+
granularity: options.granularity || 'day',
|
|
992
|
+
byEvent: options.byEvent || false,
|
|
993
|
+
siteId: this.site_id,
|
|
994
|
+
dateRange: {
|
|
995
|
+
start: options.startDate?.toISOString(),
|
|
996
|
+
end: options.endDate?.toISOString()
|
|
997
|
+
},
|
|
998
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async runSqlQuery(query: string, options: { limit?: number } = {}) {
|
|
1004
|
+
const normalized = normalizeSqlQuery(query);
|
|
1005
|
+
const validationError = validateSqlQuery(normalized);
|
|
1006
|
+
|
|
1007
|
+
if (validationError) {
|
|
1008
|
+
return {
|
|
1009
|
+
success: false,
|
|
1010
|
+
error: validationError,
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const limit = Math.min(options.limit ?? MAX_SQL_ROWS, MAX_SQL_ROWS);
|
|
1015
|
+
// Check if query already has a LIMIT clause (handles "LIMIT 50" at end or "LIMIT 50 OFFSET 10")
|
|
1016
|
+
const hasLimit = /\blimit\s+\d+/i.test(normalized);
|
|
1017
|
+
const limitedQuery = hasLimit
|
|
1018
|
+
? normalized
|
|
1019
|
+
: `${normalized} LIMIT ${limit}`;
|
|
1020
|
+
|
|
1021
|
+
try {
|
|
1022
|
+
const storage = this.state.storage;
|
|
1023
|
+
const cursor = storage.sql.exec(limitedQuery);
|
|
1024
|
+
const rows = cursor.toArray();
|
|
1025
|
+
|
|
1026
|
+
return {
|
|
1027
|
+
success: true,
|
|
1028
|
+
rows,
|
|
1029
|
+
rowCount: rows.length,
|
|
1030
|
+
limit,
|
|
1031
|
+
};
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
return {
|
|
1034
|
+
success: false,
|
|
1035
|
+
error: error instanceof Error ? error.message : "Query failed",
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Get specific metrics for dashboard widgets
|
|
1042
|
+
*/
|
|
1043
|
+
async getMetrics(options: {
|
|
1044
|
+
startDate?: Date;
|
|
1045
|
+
endDate?: Date;
|
|
1046
|
+
endDateIsExact?: boolean;
|
|
1047
|
+
metricType: 'events' | 'countries' | 'devices' | 'referers' | 'pages';
|
|
1048
|
+
limit?: number;
|
|
1049
|
+
}) {
|
|
1050
|
+
try {
|
|
1051
|
+
const { startDate, endDate, endDateIsExact, metricType, limit = 10 } = options;
|
|
1052
|
+
|
|
1053
|
+
// Build date filter conditions
|
|
1054
|
+
const conditions = [];
|
|
1055
|
+
if (startDate) {
|
|
1056
|
+
conditions.push(gte(siteEvents.createdAt, startDate));
|
|
1057
|
+
}
|
|
1058
|
+
if (endDate) {
|
|
1059
|
+
conditions.push(lte(siteEvents.createdAt, resolveEndDate(endDate, endDateIsExact)));
|
|
1060
|
+
}
|
|
1061
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
1062
|
+
|
|
1063
|
+
let data;
|
|
1064
|
+
switch (metricType) {
|
|
1065
|
+
case 'events':
|
|
1066
|
+
data = await this.db
|
|
1067
|
+
.select({
|
|
1068
|
+
label: siteEvents.event,
|
|
1069
|
+
count: count()
|
|
1070
|
+
})
|
|
1071
|
+
.from(siteEvents)
|
|
1072
|
+
.where(whereClause)
|
|
1073
|
+
.groupBy(siteEvents.event)
|
|
1074
|
+
.orderBy(desc(count()))
|
|
1075
|
+
.limit(limit);
|
|
1076
|
+
break;
|
|
1077
|
+
case 'countries':
|
|
1078
|
+
data = await this.db
|
|
1079
|
+
.select({
|
|
1080
|
+
label: siteEvents.country,
|
|
1081
|
+
count: count()
|
|
1082
|
+
})
|
|
1083
|
+
.from(siteEvents)
|
|
1084
|
+
.where(whereClause)
|
|
1085
|
+
.groupBy(siteEvents.country)
|
|
1086
|
+
.orderBy(desc(count()))
|
|
1087
|
+
.limit(limit);
|
|
1088
|
+
break;
|
|
1089
|
+
case 'devices':
|
|
1090
|
+
data = await this.db
|
|
1091
|
+
.select({
|
|
1092
|
+
label: siteEvents.device_type,
|
|
1093
|
+
count: count()
|
|
1094
|
+
})
|
|
1095
|
+
.from(siteEvents)
|
|
1096
|
+
.where(whereClause)
|
|
1097
|
+
.groupBy(siteEvents.device_type)
|
|
1098
|
+
.orderBy(desc(count()))
|
|
1099
|
+
.limit(limit);
|
|
1100
|
+
break;
|
|
1101
|
+
case 'referers':
|
|
1102
|
+
data = await this.db
|
|
1103
|
+
.select({
|
|
1104
|
+
label: siteEvents.referer,
|
|
1105
|
+
count: count()
|
|
1106
|
+
})
|
|
1107
|
+
.from(siteEvents)
|
|
1108
|
+
.where(whereClause)
|
|
1109
|
+
.groupBy(siteEvents.referer)
|
|
1110
|
+
.orderBy(desc(count()))
|
|
1111
|
+
.limit(limit);
|
|
1112
|
+
break;
|
|
1113
|
+
case 'pages':
|
|
1114
|
+
data = await this.db
|
|
1115
|
+
.select({
|
|
1116
|
+
label: siteEvents.page_url,
|
|
1117
|
+
count: count()
|
|
1118
|
+
})
|
|
1119
|
+
.from(siteEvents)
|
|
1120
|
+
.where(whereClause)
|
|
1121
|
+
.groupBy(siteEvents.page_url)
|
|
1122
|
+
.orderBy(desc(count()))
|
|
1123
|
+
.limit(limit);
|
|
1124
|
+
break;
|
|
1125
|
+
default:
|
|
1126
|
+
throw new Error('Invalid metric type. Use: events, countries, devices, referers, or pages');
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return {
|
|
1130
|
+
metricType,
|
|
1131
|
+
data: data.map(item => ({ label: item.label || 'Unknown', count: item.count })),
|
|
1132
|
+
siteId: this.site_id,
|
|
1133
|
+
dateRange: {
|
|
1134
|
+
start: startDate?.toISOString(),
|
|
1135
|
+
end: endDate?.toISOString()
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
if (this.env.ENVIRONMENT === "development") console.error(`SiteDurableObject getMetrics error for site ${this.site_id}:`, error);
|
|
1140
|
+
return {
|
|
1141
|
+
metricType: options.metricType,
|
|
1142
|
+
data: [],
|
|
1143
|
+
siteId: this.site_id,
|
|
1144
|
+
dateRange: {
|
|
1145
|
+
start: options.startDate?.toISOString(),
|
|
1146
|
+
end: options.endDate?.toISOString()
|
|
1147
|
+
},
|
|
1148
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Delete events (for cleanup/testing)
|
|
1155
|
+
*/
|
|
1156
|
+
async deleteEvents(options: {
|
|
1157
|
+
olderThan?: Date;
|
|
1158
|
+
eventType?: string;
|
|
1159
|
+
}) {
|
|
1160
|
+
try {
|
|
1161
|
+
const { olderThan, eventType } = options;
|
|
1162
|
+
|
|
1163
|
+
if (!olderThan && !eventType) {
|
|
1164
|
+
return {
|
|
1165
|
+
success: false,
|
|
1166
|
+
deleted: '0',
|
|
1167
|
+
siteId: this.site_id,
|
|
1168
|
+
error: 'Must specify either olderThan date or event type for deletion'
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const conditions = [];
|
|
1173
|
+
if (olderThan) {
|
|
1174
|
+
conditions.push(lte(siteEvents.createdAt, olderThan));
|
|
1175
|
+
}
|
|
1176
|
+
if (eventType) {
|
|
1177
|
+
conditions.push(eq(siteEvents.event, eventType));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
await this.db
|
|
1181
|
+
.delete(siteEvents)
|
|
1182
|
+
.where(and(...conditions));
|
|
1183
|
+
|
|
1184
|
+
return {
|
|
1185
|
+
success: true,
|
|
1186
|
+
deleted: 'unknown', // D1 doesn't return rowsAffected in durable objects
|
|
1187
|
+
siteId: this.site_id
|
|
1188
|
+
};
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
if (this.env.ENVIRONMENT === "development") console.error(`SiteDurableObject deleteEvents error for site ${this.site_id}:`, error);
|
|
1191
|
+
return {
|
|
1192
|
+
success: false,
|
|
1193
|
+
deleted: '0',
|
|
1194
|
+
siteId: this.site_id,
|
|
1195
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Get an approximate count of current visitors.
|
|
1202
|
+
*
|
|
1203
|
+
* Uses distinct `rid` values over a rolling time window.
|
|
1204
|
+
*/
|
|
1205
|
+
async getCurrentVisitors(options: { windowSeconds?: number } = {}) {
|
|
1206
|
+
try {
|
|
1207
|
+
const windowSeconds = Math.max(1, options.windowSeconds ?? 60 * 5);
|
|
1208
|
+
const startDate = new Date(Date.now() - windowSeconds * 1000);
|
|
1209
|
+
|
|
1210
|
+
const conditions = [
|
|
1211
|
+
gte(siteEvents.createdAt, startDate),
|
|
1212
|
+
isNotNull(siteEvents.rid),
|
|
1213
|
+
ne(siteEvents.rid, ""),
|
|
1214
|
+
];
|
|
1215
|
+
|
|
1216
|
+
const result = await this.db
|
|
1217
|
+
.select({
|
|
1218
|
+
count: sql<number>`COUNT(DISTINCT ${siteEvents.rid})`,
|
|
1219
|
+
})
|
|
1220
|
+
.from(siteEvents)
|
|
1221
|
+
.where(and(...conditions));
|
|
1222
|
+
|
|
1223
|
+
return {
|
|
1224
|
+
currentVisitors: result[0]?.count || 0,
|
|
1225
|
+
windowSeconds,
|
|
1226
|
+
siteId: this.site_id,
|
|
1227
|
+
timestamp: new Date().toISOString(),
|
|
1228
|
+
};
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
if (this.env.ENVIRONMENT === "development") console.error(`SiteDurableObject getCurrentVisitors error for site ${this.site_id}:`, error);
|
|
1231
|
+
return {
|
|
1232
|
+
currentVisitors: 0,
|
|
1233
|
+
windowSeconds: options.windowSeconds ?? 60 * 5,
|
|
1234
|
+
siteId: this.site_id,
|
|
1235
|
+
timestamp: new Date().toISOString(),
|
|
1236
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
async countEventsSince(options: { startDate: Date; endDate?: Date }) {
|
|
1242
|
+
if (!this.site_id) {
|
|
1243
|
+
return {
|
|
1244
|
+
count: 0,
|
|
1245
|
+
siteId: this.site_id,
|
|
1246
|
+
error: "Site not initialized",
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const conditions = [gte(siteEvents.createdAt, options.startDate)];
|
|
1251
|
+
if (options.endDate) {
|
|
1252
|
+
conditions.push(lte(siteEvents.createdAt, options.endDate));
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
1256
|
+
|
|
1257
|
+
const result = await this.db
|
|
1258
|
+
.select({ count: count() })
|
|
1259
|
+
.from(siteEvents)
|
|
1260
|
+
.where(whereClause);
|
|
1261
|
+
|
|
1262
|
+
return {
|
|
1263
|
+
count: result[0]?.count || 0,
|
|
1264
|
+
siteId: this.site_id,
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Get schema information for the database tables.
|
|
1270
|
+
* Returns column definitions and index information using SQLite PRAGMA.
|
|
1271
|
+
*/
|
|
1272
|
+
async getSchema() {
|
|
1273
|
+
try {
|
|
1274
|
+
const storage = this.state.storage;
|
|
1275
|
+
|
|
1276
|
+
// Get table info using PRAGMA
|
|
1277
|
+
const tableInfoCursor = storage.sql.exec("PRAGMA table_info(site_events)");
|
|
1278
|
+
const columns = tableInfoCursor.toArray().map((col: Record<string, unknown>) => ({
|
|
1279
|
+
name: col.name as string,
|
|
1280
|
+
type: col.type as string,
|
|
1281
|
+
nullable: col.notnull === 0,
|
|
1282
|
+
primaryKey: col.pk === 1,
|
|
1283
|
+
defaultValue: col.dflt_value as string | null,
|
|
1284
|
+
}));
|
|
1285
|
+
|
|
1286
|
+
// Get index info
|
|
1287
|
+
const indexListCursor = storage.sql.exec("PRAGMA index_list(site_events)");
|
|
1288
|
+
const indexList = indexListCursor.toArray();
|
|
1289
|
+
|
|
1290
|
+
const indexes: { name: string; columns: string[]; unique: boolean }[] = [];
|
|
1291
|
+
for (const idx of indexList) {
|
|
1292
|
+
const indexName = idx.name as string;
|
|
1293
|
+
const indexInfoCursor = storage.sql.exec(`PRAGMA index_info("${indexName}")`);
|
|
1294
|
+
const indexColumns = indexInfoCursor.toArray().map((c: Record<string, unknown>) => c.name as string);
|
|
1295
|
+
indexes.push({
|
|
1296
|
+
name: indexName,
|
|
1297
|
+
columns: indexColumns,
|
|
1298
|
+
unique: idx.unique === 1,
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return {
|
|
1303
|
+
success: true,
|
|
1304
|
+
tables: [
|
|
1305
|
+
{
|
|
1306
|
+
name: "site_events",
|
|
1307
|
+
columns,
|
|
1308
|
+
indexes,
|
|
1309
|
+
},
|
|
1310
|
+
],
|
|
1311
|
+
siteId: this.site_id,
|
|
1312
|
+
};
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
if (this.env.ENVIRONMENT === "development") console.error(`SiteDurableObject getSchema error:`, error);
|
|
1315
|
+
return {
|
|
1316
|
+
success: false,
|
|
1317
|
+
tables: [],
|
|
1318
|
+
siteId: this.site_id,
|
|
1319
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Health check endpoint
|
|
1326
|
+
*/
|
|
1327
|
+
async healthCheck() {
|
|
1328
|
+
try {
|
|
1329
|
+
const result = await this.db
|
|
1330
|
+
.select({ count: count() })
|
|
1331
|
+
.from(siteEvents);
|
|
1332
|
+
|
|
1333
|
+
const totalEvents = result[0]?.count || 0;
|
|
1334
|
+
|
|
1335
|
+
return {
|
|
1336
|
+
status: 'healthy',
|
|
1337
|
+
siteId: this.site_id,
|
|
1338
|
+
totalEvents,
|
|
1339
|
+
timestamp: new Date().toISOString()
|
|
1340
|
+
};
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
if (this.env.ENVIRONMENT === "development") console.error(`SiteDurableObject healthCheck error for site ${this.site_id}:`, error);
|
|
1343
|
+
return {
|
|
1344
|
+
status: 'error',
|
|
1345
|
+
siteId: this.site_id,
|
|
1346
|
+
totalEvents: 0,
|
|
1347
|
+
timestamp: new Date().toISOString(),
|
|
1348
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|