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,167 @@
|
|
|
1
|
+
export type DateRangeFilter = {
|
|
2
|
+
start?: Date;
|
|
3
|
+
end?: Date;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function isWithinDateRange(date: Date, range?: DateRangeFilter): boolean {
|
|
7
|
+
if (range?.start && date < range.start) return false;
|
|
8
|
+
if (range?.end && date > range.end) return false;
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function filterByDateRange<T>(
|
|
13
|
+
items: T[],
|
|
14
|
+
getDate: (item: T) => Date | null | undefined,
|
|
15
|
+
range?: DateRangeFilter,
|
|
16
|
+
): T[] {
|
|
17
|
+
if (!range?.start && !range?.end) return items;
|
|
18
|
+
return items.filter((item) => {
|
|
19
|
+
const date = getDate(item);
|
|
20
|
+
if (!date) return false;
|
|
21
|
+
return isWithinDateRange(date, range);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toDateKey(date: Date): string {
|
|
26
|
+
return date.toISOString().split("T")[0];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function countBy<T, K>(
|
|
30
|
+
items: T[],
|
|
31
|
+
getKey: (item: T) => K | null | undefined,
|
|
32
|
+
): Map<K, number> {
|
|
33
|
+
const map = new Map<K, number>();
|
|
34
|
+
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
const key = getKey(item);
|
|
37
|
+
if (key === null || key === undefined) continue;
|
|
38
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return map;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function countDistinctBy<T, K>(
|
|
45
|
+
items: T[],
|
|
46
|
+
getKey: (item: T) => K | null | undefined,
|
|
47
|
+
): number {
|
|
48
|
+
const set = new Set<K>();
|
|
49
|
+
for (const item of items) {
|
|
50
|
+
const key = getKey(item);
|
|
51
|
+
if (key === null || key === undefined) continue;
|
|
52
|
+
set.add(key);
|
|
53
|
+
}
|
|
54
|
+
return set.size;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function mapToSortedEntries<K>(
|
|
58
|
+
map: Map<K, number>,
|
|
59
|
+
options?: { direction?: "asc" | "desc"; limit?: number },
|
|
60
|
+
): Array<[K, number]> {
|
|
61
|
+
const direction = options?.direction ?? "desc";
|
|
62
|
+
const entries = Array.from(map.entries()).toSorted((a, b) =>
|
|
63
|
+
direction === "desc" ? b[1] - a[1] : a[1] - b[1],
|
|
64
|
+
);
|
|
65
|
+
return typeof options?.limit === "number" ? entries.slice(0, options.limit) : entries;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatPercent(value: number, decimals: number): string {
|
|
69
|
+
if (!Number.isFinite(value)) return `0.${"0".repeat(decimals)}%`;
|
|
70
|
+
return `${value.toFixed(decimals)}%`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatDurationSeconds(totalSeconds: number): string {
|
|
74
|
+
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "0s";
|
|
75
|
+
|
|
76
|
+
const wholeSeconds = Math.floor(totalSeconds);
|
|
77
|
+
const minutes = Math.floor(wholeSeconds / 60);
|
|
78
|
+
const seconds = wholeSeconds % 60;
|
|
79
|
+
|
|
80
|
+
if (minutes > 0) {
|
|
81
|
+
return `${minutes}m ${seconds}s`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return `${seconds}s`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function cleanReferer(referer: unknown): string {
|
|
88
|
+
if (!referer || referer === "" || referer === "null") {
|
|
89
|
+
return "Direct";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const value = String(referer);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const url = new URL(value);
|
|
96
|
+
return url.hostname;
|
|
97
|
+
} catch {
|
|
98
|
+
const hostname = value.replace(/^https?:\/\//, "").replace(/\/.*/, "");
|
|
99
|
+
if (hostname.length === 0) return "Direct";
|
|
100
|
+
return hostname.length > 50 ? `${hostname.substring(0, 47)}...` : hostname;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function cleanPageUrl(url: unknown): string {
|
|
105
|
+
if (!url || url === "" || url === "null") {
|
|
106
|
+
return "Unknown";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const value = String(url);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const urlObj = new URL(value);
|
|
113
|
+
return urlObj.pathname + (urlObj.search ? urlObj.search : "");
|
|
114
|
+
} catch {
|
|
115
|
+
return value.length > 50 ? `${value.substring(0, 47)}...` : value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function serializeForClient<T>(obj: T): T {
|
|
120
|
+
return JSON.parse(
|
|
121
|
+
JSON.stringify(obj, (_key, value) => {
|
|
122
|
+
if (value instanceof Date) {
|
|
123
|
+
return value.toISOString();
|
|
124
|
+
}
|
|
125
|
+
if (value === undefined) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function calculateAverageSessionDurationSeconds<T>(
|
|
134
|
+
events: T[],
|
|
135
|
+
selectors: {
|
|
136
|
+
getSessionId: (event: T) => string | null | undefined;
|
|
137
|
+
getTimestamp: (event: T) => Date | null | undefined;
|
|
138
|
+
},
|
|
139
|
+
): number {
|
|
140
|
+
const sessionTimes = new Map<string, { first: Date; last: Date }>();
|
|
141
|
+
|
|
142
|
+
for (const event of events) {
|
|
143
|
+
const sessionId = selectors.getSessionId(event);
|
|
144
|
+
const timestamp = selectors.getTimestamp(event);
|
|
145
|
+
if (!sessionId || !timestamp) continue;
|
|
146
|
+
|
|
147
|
+
const existing = sessionTimes.get(sessionId);
|
|
148
|
+
if (!existing) {
|
|
149
|
+
sessionTimes.set(sessionId, { first: timestamp, last: timestamp });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (timestamp < existing.first) existing.first = timestamp;
|
|
154
|
+
if (timestamp > existing.last) existing.last = timestamp;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (sessionTimes.size === 0) return 0;
|
|
158
|
+
|
|
159
|
+
let durationSumSeconds = 0;
|
|
160
|
+
for (const { first, last } of sessionTimes.values()) {
|
|
161
|
+
durationSumSeconds += (last.getTime() - first.getTime()) / 1000;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return durationSumSeconds / sessionTimes.size;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Validation Functions for Durable Object Migration
|
|
3
|
+
*
|
|
4
|
+
* These functions validate data integrity during migration from original databases
|
|
5
|
+
* to site-specific durable objects, ensuring consistency and completeness.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SiteEventInput } from "@/session/siteSchema";
|
|
9
|
+
import { getDashboardDataFromDurableObject } from "@db/durable/durableObjectClient";
|
|
10
|
+
import type { DashboardOptions } from "@db/types";
|
|
11
|
+
import { IS_DEV } from "rwsdk/constants";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validation result interface
|
|
15
|
+
*/
|
|
16
|
+
export interface ValidationResult {
|
|
17
|
+
isValid: boolean;
|
|
18
|
+
errors: string[];
|
|
19
|
+
warnings: string[];
|
|
20
|
+
recordCount?: number;
|
|
21
|
+
validRecords?: number;
|
|
22
|
+
invalidRecords?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Site event validation configuration
|
|
27
|
+
*/
|
|
28
|
+
export interface ValidationConfig {
|
|
29
|
+
strictMode?: boolean; // If true, warnings become errors
|
|
30
|
+
allowEmptyFields?: string[]; // Fields that can be empty/null
|
|
31
|
+
maxStringLength?: number; // Maximum string field length
|
|
32
|
+
dateRange?: {
|
|
33
|
+
minDate?: Date;
|
|
34
|
+
maxDate?: Date;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default validation configuration
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_CONFIG: ValidationConfig = {
|
|
42
|
+
strictMode: false,
|
|
43
|
+
allowEmptyFields: ['bot_data', 'custom_data', 'query_params', 'rid', 'postal', 'region', 'city', 'country'],
|
|
44
|
+
maxStringLength: 2000,
|
|
45
|
+
dateRange: {
|
|
46
|
+
minDate: new Date('2020-01-01'), // Reasonable minimum date
|
|
47
|
+
maxDate: new Date(Date.now() + 24 * 60 * 60 * 1000) // Allow up to 1 day in future
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate a single site event record
|
|
53
|
+
*/
|
|
54
|
+
export function validateSiteEvent(
|
|
55
|
+
event: SiteEventInput,
|
|
56
|
+
config: ValidationConfig = DEFAULT_CONFIG
|
|
57
|
+
): ValidationResult {
|
|
58
|
+
const errors: string[] = [];
|
|
59
|
+
const warnings: string[] = [];
|
|
60
|
+
|
|
61
|
+
// Required field validation
|
|
62
|
+
if (!event.event || typeof event.event !== 'string') {
|
|
63
|
+
errors.push('Field "event" is required and must be a string');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!event.tag_id || typeof event.tag_id !== 'string') {
|
|
67
|
+
errors.push('Field "tag_id" is required and must be a string');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// String length validation
|
|
71
|
+
const stringFields = ['event', 'tag_id', 'browser', 'city', 'client_page_url', 'country',
|
|
72
|
+
'device_type', 'operating_system', 'page_url', 'postal', 'referer',
|
|
73
|
+
'region', 'rid'];
|
|
74
|
+
|
|
75
|
+
for (const field of stringFields) {
|
|
76
|
+
const value = event[field as keyof SiteEventInput];
|
|
77
|
+
if (value && typeof value === 'string' && value.length > (config.maxStringLength || 2000)) {
|
|
78
|
+
errors.push(`Field "${field}" exceeds maximum length of ${config.maxStringLength}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Numeric field validation
|
|
83
|
+
if (event.screen_height !== undefined && (typeof event.screen_height !== 'number' || event.screen_height < 0)) {
|
|
84
|
+
errors.push('Field "screen_height" must be a positive number');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (event.screen_width !== undefined && (typeof event.screen_width !== 'number' || event.screen_width < 0)) {
|
|
88
|
+
errors.push('Field "screen_width" must be a positive number');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Date validation
|
|
92
|
+
if (event.createdAt) {
|
|
93
|
+
const date = new Date(event.createdAt);
|
|
94
|
+
if (isNaN(date.getTime())) {
|
|
95
|
+
errors.push('Field "createdAt" must be a valid date');
|
|
96
|
+
} else {
|
|
97
|
+
const { minDate, maxDate } = config.dateRange || {};
|
|
98
|
+
if (minDate && date < minDate) {
|
|
99
|
+
warnings.push(`Field "createdAt" is before minimum date ${minDate.toISOString()}`);
|
|
100
|
+
}
|
|
101
|
+
if (maxDate && date > maxDate) {
|
|
102
|
+
warnings.push(`Field "createdAt" is after maximum date ${maxDate.toISOString()}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// JSON field validation
|
|
108
|
+
const jsonFields = ['bot_data', 'custom_data', 'query_params'];
|
|
109
|
+
for (const field of jsonFields) {
|
|
110
|
+
const value = event[field as keyof SiteEventInput];
|
|
111
|
+
if (value !== undefined && value !== null) {
|
|
112
|
+
try {
|
|
113
|
+
if (typeof value === 'string') {
|
|
114
|
+
JSON.parse(value);
|
|
115
|
+
} else if (typeof value !== 'object') {
|
|
116
|
+
errors.push(`Field "${field}" must be a valid JSON object or string`);
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
errors.push(`Field "${field}" contains invalid JSON`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// URL validation (basic)
|
|
125
|
+
const urlFields = ['client_page_url', 'page_url', 'referer'];
|
|
126
|
+
for (const field of urlFields) {
|
|
127
|
+
const value = event[field as keyof SiteEventInput];
|
|
128
|
+
if (value && typeof value === 'string') {
|
|
129
|
+
try {
|
|
130
|
+
new URL(value);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
warnings.push(`Field "${field}" does not appear to be a valid URL: ${value}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Convert warnings to errors in strict mode
|
|
138
|
+
if (config.strictMode) {
|
|
139
|
+
errors.push(...warnings);
|
|
140
|
+
warnings.length = 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
isValid: errors.length === 0,
|
|
145
|
+
errors,
|
|
146
|
+
warnings,
|
|
147
|
+
recordCount: 1,
|
|
148
|
+
validRecords: errors.length === 0 ? 1 : 0,
|
|
149
|
+
invalidRecords: errors.length > 0 ? 1 : 0
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Validate an array of site events
|
|
155
|
+
*/
|
|
156
|
+
export function validateSiteEvents(
|
|
157
|
+
events: SiteEventInput[],
|
|
158
|
+
config: ValidationConfig = DEFAULT_CONFIG
|
|
159
|
+
): ValidationResult {
|
|
160
|
+
const allErrors: string[] = [];
|
|
161
|
+
const allWarnings: string[] = [];
|
|
162
|
+
let validRecords = 0;
|
|
163
|
+
let invalidRecords = 0;
|
|
164
|
+
|
|
165
|
+
if (!Array.isArray(events)) {
|
|
166
|
+
return {
|
|
167
|
+
isValid: false,
|
|
168
|
+
errors: ['Input must be an array of events'],
|
|
169
|
+
warnings: [],
|
|
170
|
+
recordCount: 0,
|
|
171
|
+
validRecords: 0,
|
|
172
|
+
invalidRecords: 0
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
events.forEach((event, index) => {
|
|
177
|
+
const result = validateSiteEvent(event, config);
|
|
178
|
+
|
|
179
|
+
if (result.isValid) {
|
|
180
|
+
validRecords++;
|
|
181
|
+
} else {
|
|
182
|
+
invalidRecords++;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Prefix errors and warnings with record index
|
|
186
|
+
result.errors.forEach(error => {
|
|
187
|
+
allErrors.push(`Record ${index}: ${error}`);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
result.warnings.forEach(warning => {
|
|
191
|
+
allWarnings.push(`Record ${index}: ${warning}`);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
isValid: invalidRecords === 0,
|
|
197
|
+
errors: allErrors,
|
|
198
|
+
warnings: allWarnings,
|
|
199
|
+
recordCount: events.length,
|
|
200
|
+
validRecords,
|
|
201
|
+
invalidRecords
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Compare record counts between original database and durable object
|
|
207
|
+
*/
|
|
208
|
+
export async function validateRecordCounts(
|
|
209
|
+
siteId: number,
|
|
210
|
+
originalCount: number,
|
|
211
|
+
env: Env,
|
|
212
|
+
dateRange?: { start: Date; end: Date }
|
|
213
|
+
): Promise<ValidationResult> {
|
|
214
|
+
const errors: string[] = [];
|
|
215
|
+
const warnings: string[] = [];
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
// Get count from durable object
|
|
219
|
+
const options: DashboardOptions = {
|
|
220
|
+
site_id: siteId,
|
|
221
|
+
site_uuid: `site-${siteId}`,
|
|
222
|
+
team_id: 1, // TODO: Get actual team_id
|
|
223
|
+
date: dateRange
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const dashboardData = await getDashboardDataFromDurableObject(options);
|
|
227
|
+
const durableObjectCount = dashboardData.query?.events?.length || 0;
|
|
228
|
+
|
|
229
|
+
// Compare counts
|
|
230
|
+
if (durableObjectCount !== originalCount) {
|
|
231
|
+
const difference = Math.abs(durableObjectCount - originalCount);
|
|
232
|
+
const percentageDiff = originalCount > 0 ? (difference / originalCount) * 100 : 100;
|
|
233
|
+
|
|
234
|
+
if (percentageDiff > 5) { // More than 5% difference is an error
|
|
235
|
+
errors.push(
|
|
236
|
+
`Significant count mismatch for site ${siteId}: ` +
|
|
237
|
+
`Original DB: ${originalCount}, Durable Object: ${durableObjectCount} ` +
|
|
238
|
+
`(${percentageDiff.toFixed(2)}% difference)`
|
|
239
|
+
);
|
|
240
|
+
} else {
|
|
241
|
+
warnings.push(
|
|
242
|
+
`Minor count mismatch for site ${siteId}: ` +
|
|
243
|
+
`Original DB: ${originalCount}, Durable Object: ${durableObjectCount} ` +
|
|
244
|
+
`(${percentageDiff.toFixed(2)}% difference)`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
isValid: errors.length === 0,
|
|
251
|
+
errors,
|
|
252
|
+
warnings,
|
|
253
|
+
recordCount: durableObjectCount,
|
|
254
|
+
validRecords: durableObjectCount,
|
|
255
|
+
invalidRecords: 0
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return {
|
|
260
|
+
isValid: false,
|
|
261
|
+
errors: [`Failed to validate record counts for site ${siteId}: ${error instanceof Error ? error.message : String(error)}`],
|
|
262
|
+
warnings: [],
|
|
263
|
+
recordCount: 0,
|
|
264
|
+
validRecords: 0,
|
|
265
|
+
invalidRecords: 0
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Validate data consistency between original database and durable object
|
|
272
|
+
* Compares a sample of records to ensure data integrity
|
|
273
|
+
*/
|
|
274
|
+
export async function validateDataConsistency(
|
|
275
|
+
siteId: number,
|
|
276
|
+
originalEvents: SiteEventInput[],
|
|
277
|
+
env: Env,
|
|
278
|
+
sampleSize: number = 100
|
|
279
|
+
): Promise<ValidationResult> {
|
|
280
|
+
const errors: string[] = [];
|
|
281
|
+
const warnings: string[] = [];
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
// Get events from durable object
|
|
285
|
+
const options: DashboardOptions = {
|
|
286
|
+
site_id: siteId,
|
|
287
|
+
site_uuid: `site-${siteId}`,
|
|
288
|
+
team_id: 1, // TODO: Get actual team_id
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const dashboardData = await getDashboardDataFromDurableObject(options);
|
|
292
|
+
const durableObjectEvents = dashboardData.query?.events || [];
|
|
293
|
+
|
|
294
|
+
if (durableObjectEvents.length === 0) {
|
|
295
|
+
errors.push(`No events found in durable object for site ${siteId}`);
|
|
296
|
+
return { isValid: false, errors, warnings };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Sample events for comparison (take first N events)
|
|
300
|
+
const sampleOriginal = originalEvents.slice(0, Math.min(sampleSize, originalEvents.length));
|
|
301
|
+
const sampleDurableObject = durableObjectEvents.slice(0, Math.min(sampleSize, durableObjectEvents.length));
|
|
302
|
+
|
|
303
|
+
// Compare key fields for sampled records
|
|
304
|
+
const keyFields = ['event', 'tag_id', 'country', 'device_type', 'browser'];
|
|
305
|
+
|
|
306
|
+
for (let i = 0; i < Math.min(sampleOriginal.length, sampleDurableObject.length); i++) {
|
|
307
|
+
const original = sampleOriginal[i];
|
|
308
|
+
const durableObj = sampleDurableObject[i];
|
|
309
|
+
|
|
310
|
+
for (const field of keyFields) {
|
|
311
|
+
const originalValue = original[field as keyof SiteEventInput];
|
|
312
|
+
const durableObjValue = durableObj[field as keyof SiteEventInput];
|
|
313
|
+
|
|
314
|
+
if (originalValue !== durableObjValue) {
|
|
315
|
+
warnings.push(
|
|
316
|
+
`Data mismatch in record ${i}, field "${field}": ` +
|
|
317
|
+
`Original: "${originalValue}", Durable Object: "${durableObjValue}"`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
isValid: errors.length === 0,
|
|
325
|
+
errors,
|
|
326
|
+
warnings,
|
|
327
|
+
recordCount: Math.min(sampleOriginal.length, sampleDurableObject.length),
|
|
328
|
+
validRecords: Math.min(sampleOriginal.length, sampleDurableObject.length) - warnings.length,
|
|
329
|
+
invalidRecords: warnings.length
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return {
|
|
334
|
+
isValid: false,
|
|
335
|
+
errors: [`Failed to validate data consistency for site ${siteId}: ${error instanceof Error ? error.message : String(error)}`],
|
|
336
|
+
warnings: [],
|
|
337
|
+
recordCount: 0,
|
|
338
|
+
validRecords: 0,
|
|
339
|
+
invalidRecords: 0
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Comprehensive validation suite for site migration
|
|
346
|
+
*/
|
|
347
|
+
export async function validateSiteMigration(
|
|
348
|
+
siteId: number,
|
|
349
|
+
originalEvents: SiteEventInput[],
|
|
350
|
+
originalCount: number,
|
|
351
|
+
env: Env,
|
|
352
|
+
config: ValidationConfig = DEFAULT_CONFIG
|
|
353
|
+
): Promise<ValidationResult> {
|
|
354
|
+
const allErrors: string[] = [];
|
|
355
|
+
const allWarnings: string[] = [];
|
|
356
|
+
|
|
357
|
+
// 1. Validate event data structure
|
|
358
|
+
if (IS_DEV) console.log(`Validating ${originalEvents.length} events for site ${siteId}...`);
|
|
359
|
+
const structureValidation = validateSiteEvents(originalEvents, config);
|
|
360
|
+
allErrors.push(...structureValidation.errors);
|
|
361
|
+
allWarnings.push(...structureValidation.warnings);
|
|
362
|
+
|
|
363
|
+
// 2. Validate record counts
|
|
364
|
+
if (IS_DEV) console.log(`Validating record counts for site ${siteId}...`);
|
|
365
|
+
const countValidation = await validateRecordCounts(siteId, originalCount, env);
|
|
366
|
+
allErrors.push(...countValidation.errors);
|
|
367
|
+
allWarnings.push(...countValidation.warnings);
|
|
368
|
+
|
|
369
|
+
// 3. Validate data consistency (sample)
|
|
370
|
+
if (IS_DEV) console.log(`Validating data consistency for site ${siteId}...`);
|
|
371
|
+
const consistencyValidation = await validateDataConsistency(siteId, originalEvents, env);
|
|
372
|
+
allErrors.push(...consistencyValidation.errors);
|
|
373
|
+
allWarnings.push(...consistencyValidation.warnings);
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
isValid: allErrors.length === 0,
|
|
377
|
+
errors: allErrors,
|
|
378
|
+
warnings: allWarnings,
|
|
379
|
+
recordCount: originalEvents.length,
|
|
380
|
+
validRecords: structureValidation.validRecords || 0,
|
|
381
|
+
invalidRecords: structureValidation.invalidRecords || 0
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Generate validation report
|
|
387
|
+
*/
|
|
388
|
+
export function generateValidationReport(result: ValidationResult, siteId?: number): string {
|
|
389
|
+
const lines: string[] = [];
|
|
390
|
+
|
|
391
|
+
if (siteId) {
|
|
392
|
+
lines.push(`=== Validation Report for Site ${siteId} ===`);
|
|
393
|
+
} else {
|
|
394
|
+
lines.push(`=== Validation Report ===`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
lines.push(`Status: ${result.isValid ? 'PASSED' : 'FAILED'}`);
|
|
398
|
+
lines.push(`Total Records: ${result.recordCount || 0}`);
|
|
399
|
+
lines.push(`Valid Records: ${result.validRecords || 0}`);
|
|
400
|
+
lines.push(`Invalid Records: ${result.invalidRecords || 0}`);
|
|
401
|
+
|
|
402
|
+
if (result.errors.length > 0) {
|
|
403
|
+
lines.push(`\nErrors (${result.errors.length}):`);
|
|
404
|
+
result.errors.forEach(error => lines.push(` - ${error}`));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (result.warnings.length > 0) {
|
|
408
|
+
lines.push(`\nWarnings (${result.warnings.length}):`);
|
|
409
|
+
result.warnings.forEach(warning => lines.push(` - ${warning}`));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
lines.push('');
|
|
413
|
+
return lines.join('\n');
|
|
414
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import DeviceDetector from "device-detector-js";
|
|
2
|
+
|
|
3
|
+
enum BROWSER_ENUM {
|
|
4
|
+
EDGE,
|
|
5
|
+
INTERNET_EXPLORER,
|
|
6
|
+
FIRE_FOX,
|
|
7
|
+
OPERA,
|
|
8
|
+
UC_BROWSER,
|
|
9
|
+
SAMSUNG_BROWSER,
|
|
10
|
+
CHROME,
|
|
11
|
+
SAFARI,
|
|
12
|
+
UNKNOWN,
|
|
13
|
+
}
|
|
14
|
+
export function parseUserAgent(userAgent: string) {
|
|
15
|
+
const deviceDetector = new DeviceDetector();
|
|
16
|
+
const device = deviceDetector.parse(userAgent);
|
|
17
|
+
|
|
18
|
+
return device;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseBrowser(
|
|
22
|
+
device: DeviceDetector.DeviceDetectorResult,
|
|
23
|
+
rawUserAgent: string
|
|
24
|
+
) {
|
|
25
|
+
if (device.client && device.client.name) {
|
|
26
|
+
return device.client.name;
|
|
27
|
+
} else return BROWSER_ENUM[detectBrowser(rawUserAgent)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function detectBrowser(userAgent: string): BROWSER_ENUM {
|
|
31
|
+
const testUserAgent = (regexp: RegExp): boolean => regexp.test(userAgent);
|
|
32
|
+
switch (true) {
|
|
33
|
+
case testUserAgent(/edg/i):
|
|
34
|
+
return BROWSER_ENUM.EDGE;
|
|
35
|
+
case testUserAgent(/trident/i):
|
|
36
|
+
return BROWSER_ENUM.INTERNET_EXPLORER;
|
|
37
|
+
case testUserAgent(/firefox|fxios/i):
|
|
38
|
+
return BROWSER_ENUM.FIRE_FOX;
|
|
39
|
+
case testUserAgent(/opr\//i):
|
|
40
|
+
return BROWSER_ENUM.OPERA;
|
|
41
|
+
case testUserAgent(/ucbrowser/i):
|
|
42
|
+
return BROWSER_ENUM.UC_BROWSER;
|
|
43
|
+
case testUserAgent(/samsungbrowser/i):
|
|
44
|
+
return BROWSER_ENUM.SAMSUNG_BROWSER;
|
|
45
|
+
case testUserAgent(/chrome|chromium|crios/i):
|
|
46
|
+
return BROWSER_ENUM.CHROME;
|
|
47
|
+
case testUserAgent(/safari/i):
|
|
48
|
+
return BROWSER_ENUM.SAFARI;
|
|
49
|
+
default:
|
|
50
|
+
return BROWSER_ENUM.UNKNOWN;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseDeviceType(
|
|
55
|
+
device: DeviceDetector.DeviceDetectorResult,
|
|
56
|
+
rawHeader: string | null
|
|
57
|
+
) {
|
|
58
|
+
if (device.device && device.device.type) {
|
|
59
|
+
return device.device.type;
|
|
60
|
+
}
|
|
61
|
+
return rawHeader ?? "Unknown";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
export function parseOs(
|
|
67
|
+
device: DeviceDetector.DeviceDetectorResult,
|
|
68
|
+
rawHeader: string | null
|
|
69
|
+
) {
|
|
70
|
+
if (device.os && device.os.name) {
|
|
71
|
+
return device.os.name;
|
|
72
|
+
} else return rawHeader ?? "Unknown";
|
|
73
|
+
}
|