@iann29/rastro 0.1.0-alpha.2 → 0.1.0-alpha.3
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/README.md +79 -30
- package/dist/component/_generated/api.d.ts +5 -5
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +10 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +4 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/convex.config.d.ts +7 -2
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +9 -4
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/diagnostics.d.ts +9 -0
- package/dist/component/diagnostics.d.ts.map +1 -0
- package/dist/component/diagnostics.js +47 -0
- package/dist/component/diagnostics.js.map +1 -0
- package/dist/component/eventStore.d.ts +11 -9
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +55 -227
- package/dist/component/eventStore.js.map +1 -1
- package/dist/component/geo.d.ts +71 -0
- package/dist/component/geo.d.ts.map +1 -0
- package/dist/component/geo.js +610 -0
- package/dist/component/geo.js.map +1 -0
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +124 -11
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +34 -14
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/reports.d.ts +8 -8
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +2 -1
- package/dist/component/reports.js.map +1 -1
- package/dist/component/schema.d.ts +15 -52
- package/dist/component/schema.js +14 -16
- package/dist/component/schema.js.map +1 -1
- package/dist/component/validators.d.ts +3 -50
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +4 -12
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +4 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +4 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +7 -6
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/benchmarks/2026-08-20-realistic.md +1 -1
- package/package.json +2 -6
- package/scripts/benchmark-ingest.mjs +81 -32
- package/src/component/_generated/api.ts +5 -5
- package/src/component/_generated/component.ts +13 -0
- package/src/component/_generated/server.ts +4 -0
- package/src/component/convex.config.ts +11 -5
- package/src/component/diagnostics.ts +64 -0
- package/src/component/eventStore.ts +78 -280
- package/src/component/geo.ts +779 -0
- package/src/component/http.ts +184 -17
- package/src/component/ingest.ts +49 -15
- package/src/component/reports.ts +4 -1
- package/src/component/schema.ts +15 -16
- package/src/component/validators.ts +4 -13
- package/src/test.ts +0 -2
- package/dist/component/migrations.d.ts +0 -17
- package/dist/component/migrations.d.ts.map +0 -1
- package/dist/component/migrations.js +0 -44
- package/dist/component/migrations.js.map +0 -1
- package/src/component/migrations.ts +0 -52
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
3
|
+
import { env, internalAction, internalMutation } from "./_generated/server.js";
|
|
4
|
+
import type { MutationCtx } from "./_generated/server.js";
|
|
5
|
+
import {
|
|
6
|
+
AGGREGATE_SHARDS,
|
|
7
|
+
DAY_MS,
|
|
8
|
+
DIMENSION_SLOTS,
|
|
9
|
+
HOURLY_AGGREGATE_SHARDS,
|
|
10
|
+
HOUR_MS,
|
|
11
|
+
MAX_BATCH_EVENT_GROUPS,
|
|
12
|
+
MAX_BATCH_EVENTS,
|
|
13
|
+
MAX_EVENTS_PER_SESSION_WINDOW,
|
|
14
|
+
} from "./constants.js";
|
|
15
|
+
import { eventBatchKey, eventBucketStart } from "./eventStore.js";
|
|
16
|
+
import { isPlainRecord } from "./guards.js";
|
|
17
|
+
import { sanitizeContext, stableHash } from "./sanitize.js";
|
|
18
|
+
import {
|
|
19
|
+
trackerEventValidator,
|
|
20
|
+
type IngestContext,
|
|
21
|
+
} from "./validators.js";
|
|
22
|
+
|
|
23
|
+
export type GeoIpProvider = "ipinfo" | "ipwhois";
|
|
24
|
+
export type GeoIpStatus = "disabled" | "ready" | "missingToken";
|
|
25
|
+
export type IpKind = "public" | "private" | "invalid" | "unavailable";
|
|
26
|
+
export type ClientIpSource = "native" | "forwarded" | "unavailable";
|
|
27
|
+
|
|
28
|
+
type GeoContext = Pick<
|
|
29
|
+
IngestContext,
|
|
30
|
+
"country" | "city" | "latitude" | "longitude"
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
type Fetcher = (
|
|
34
|
+
input: string | URL | Request,
|
|
35
|
+
init?: RequestInit,
|
|
36
|
+
) => Promise<Response>;
|
|
37
|
+
|
|
38
|
+
const GEOIP_TIMEOUT_MS = 1_500;
|
|
39
|
+
const MAX_ENRICHMENT_SESSIONS = 4;
|
|
40
|
+
const DEFAULT_GEOIP_DAILY_LIMIT = 1_000;
|
|
41
|
+
const MAX_GEOIP_DAILY_LIMIT = 1_000_000;
|
|
42
|
+
|
|
43
|
+
const geoContextValidator = v.object({
|
|
44
|
+
country: v.optional(v.string()),
|
|
45
|
+
city: v.optional(v.string()),
|
|
46
|
+
latitude: v.optional(v.number()),
|
|
47
|
+
longitude: v.optional(v.number()),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const geoCandidateEventValidator = trackerEventValidator.pick(
|
|
51
|
+
"eventId",
|
|
52
|
+
"sessionId",
|
|
53
|
+
"visitorId",
|
|
54
|
+
"timestamp",
|
|
55
|
+
"sequence",
|
|
56
|
+
);
|
|
57
|
+
const geoAggregateEventValidator = v.object({
|
|
58
|
+
sessionId: v.string(),
|
|
59
|
+
timestamp: v.number(),
|
|
60
|
+
priorCountry: v.string(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const probe = internalAction({
|
|
64
|
+
args: { ip: v.optional(v.string()) },
|
|
65
|
+
returns: v.object({
|
|
66
|
+
ipAvailable: v.boolean(),
|
|
67
|
+
requestIpKind: v.union(
|
|
68
|
+
v.literal("public"),
|
|
69
|
+
v.literal("private"),
|
|
70
|
+
v.literal("invalid"),
|
|
71
|
+
v.literal("unavailable"),
|
|
72
|
+
),
|
|
73
|
+
provider: v.union(v.literal("ipinfo"), v.literal("ipwhois"), v.null()),
|
|
74
|
+
status: v.union(
|
|
75
|
+
v.literal("disabled"),
|
|
76
|
+
v.literal("ready"),
|
|
77
|
+
v.literal("missingToken"),
|
|
78
|
+
),
|
|
79
|
+
location: v.union(geoContextValidator, v.null()),
|
|
80
|
+
}),
|
|
81
|
+
handler: async (ctx, args) => {
|
|
82
|
+
const metadata = await readRequestMetadata(ctx);
|
|
83
|
+
const provider = env.RASTRO_GEOIP_PROVIDER;
|
|
84
|
+
const status = geoIpStatus(provider, env.RASTRO_GEOIP_TOKEN);
|
|
85
|
+
const ip = args.ip && args.ip.length <= 64 ? args.ip : metadata.ip;
|
|
86
|
+
const location =
|
|
87
|
+
ip && provider && status === "ready"
|
|
88
|
+
? await resolveGeoIp(ip, provider, env.RASTRO_GEOIP_TOKEN)
|
|
89
|
+
: undefined;
|
|
90
|
+
return {
|
|
91
|
+
ipAvailable: metadata.ip !== null,
|
|
92
|
+
requestIpKind: classifyIp(metadata.ip),
|
|
93
|
+
provider: provider ?? null,
|
|
94
|
+
status,
|
|
95
|
+
location: location ?? null,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const claimEnrichment = internalMutation({
|
|
101
|
+
args: {
|
|
102
|
+
siteId: v.id("sites"),
|
|
103
|
+
events: v.array(geoCandidateEventValidator),
|
|
104
|
+
dailyLimit: v.number(),
|
|
105
|
+
},
|
|
106
|
+
returns: v.object({
|
|
107
|
+
sessionIds: v.array(v.string()),
|
|
108
|
+
events: v.array(geoAggregateEventValidator),
|
|
109
|
+
}),
|
|
110
|
+
handler: async (ctx, args) => {
|
|
111
|
+
if (
|
|
112
|
+
args.events.length === 0 ||
|
|
113
|
+
args.events.length > MAX_BATCH_EVENTS ||
|
|
114
|
+
!Number.isSafeInteger(args.dailyLimit) ||
|
|
115
|
+
args.dailyLimit < 0 ||
|
|
116
|
+
args.dailyLimit > MAX_GEOIP_DAILY_LIMIT
|
|
117
|
+
) {
|
|
118
|
+
return { sessionIds: [], events: [] };
|
|
119
|
+
}
|
|
120
|
+
const groups = new Map<
|
|
121
|
+
string,
|
|
122
|
+
{ sessionId: string; bucketStart: number; eventIds: Set<string> }
|
|
123
|
+
>();
|
|
124
|
+
for (const event of args.events) {
|
|
125
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
126
|
+
const key = eventBatchKey(event.sessionId, bucketStart);
|
|
127
|
+
const group = groups.get(key) ?? {
|
|
128
|
+
sessionId: event.sessionId,
|
|
129
|
+
bucketStart,
|
|
130
|
+
eventIds: new Set<string>(),
|
|
131
|
+
};
|
|
132
|
+
group.eventIds.add(event.eventId);
|
|
133
|
+
groups.set(key, group);
|
|
134
|
+
}
|
|
135
|
+
if (groups.size > MAX_BATCH_EVENT_GROUPS) {
|
|
136
|
+
return { sessionIds: [], events: [] };
|
|
137
|
+
}
|
|
138
|
+
const persistedEvents = new Map<string, Map<string, string | undefined>>();
|
|
139
|
+
await Promise.all(
|
|
140
|
+
[...groups].map(async ([key, group]) => {
|
|
141
|
+
const batches = await ctx.db
|
|
142
|
+
.query("eventBatches")
|
|
143
|
+
.withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
|
|
144
|
+
range
|
|
145
|
+
.eq("siteId", args.siteId)
|
|
146
|
+
.eq("sessionId", group.sessionId)
|
|
147
|
+
.eq("bucketStart", group.bucketStart),
|
|
148
|
+
)
|
|
149
|
+
.take(MAX_EVENTS_PER_SESSION_WINDOW + 1);
|
|
150
|
+
persistedEvents.set(
|
|
151
|
+
key,
|
|
152
|
+
new Map(
|
|
153
|
+
batches.flatMap((batch) =>
|
|
154
|
+
batch.events
|
|
155
|
+
.filter((event) => group.eventIds.has(event.eventId))
|
|
156
|
+
.map((event) => [
|
|
157
|
+
event.eventId,
|
|
158
|
+
event.aggregateCountry ?? event.country,
|
|
159
|
+
] as const),
|
|
160
|
+
),
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
const eligible: string[] = [];
|
|
166
|
+
const initialEvents = args.events.filter((event) => event.sequence === 0);
|
|
167
|
+
for (const event of initialEvents) {
|
|
168
|
+
if (eligible.includes(event.sessionId)) continue;
|
|
169
|
+
const persisted = persistedEvents.get(
|
|
170
|
+
eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
|
|
171
|
+
);
|
|
172
|
+
if (!persisted?.has(event.eventId)) continue;
|
|
173
|
+
const session = await ctx.db
|
|
174
|
+
.query("sessions")
|
|
175
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
176
|
+
range.eq("siteId", args.siteId).eq("sessionId", event.sessionId),
|
|
177
|
+
)
|
|
178
|
+
.unique();
|
|
179
|
+
if (
|
|
180
|
+
!session ||
|
|
181
|
+
session.visitorId !== event.visitorId ||
|
|
182
|
+
session.startedAt !== event.timestamp ||
|
|
183
|
+
session.geoLookupAttemptedAt !== undefined ||
|
|
184
|
+
(session.latitude !== undefined && session.longitude !== undefined)
|
|
185
|
+
) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
eligible.push(event.sessionId);
|
|
189
|
+
}
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
const dayStart = Math.floor(now / DAY_MS) * DAY_MS;
|
|
192
|
+
const budget = await ctx.db
|
|
193
|
+
.query("geoLookupBudgets")
|
|
194
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
195
|
+
.unique();
|
|
196
|
+
const used = budget?.dayStart === dayStart ? budget.count : 0;
|
|
197
|
+
const sessionIds = eligible.slice(
|
|
198
|
+
0,
|
|
199
|
+
Math.max(0, Math.min(MAX_ENRICHMENT_SESSIONS, args.dailyLimit - used)),
|
|
200
|
+
);
|
|
201
|
+
for (const sessionId of sessionIds) {
|
|
202
|
+
const session = await ctx.db
|
|
203
|
+
.query("sessions")
|
|
204
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
205
|
+
range.eq("siteId", args.siteId).eq("sessionId", sessionId),
|
|
206
|
+
)
|
|
207
|
+
.unique();
|
|
208
|
+
if (!session || session.geoLookupAttemptedAt !== undefined) continue;
|
|
209
|
+
await ctx.db.patch("sessions", session._id, {
|
|
210
|
+
geoLookupAttemptedAt: now,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (sessionIds.length > 0) {
|
|
214
|
+
if (budget) {
|
|
215
|
+
await ctx.db.patch("geoLookupBudgets", budget._id, {
|
|
216
|
+
dayStart,
|
|
217
|
+
count: used + sessionIds.length,
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
await ctx.db.insert("geoLookupBudgets", {
|
|
221
|
+
siteId: args.siteId,
|
|
222
|
+
dayStart,
|
|
223
|
+
count: sessionIds.length,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const claimed = new Set(sessionIds);
|
|
228
|
+
const seenEvents = new Set<string>();
|
|
229
|
+
const correctionEvents: Array<{
|
|
230
|
+
sessionId: string;
|
|
231
|
+
timestamp: number;
|
|
232
|
+
priorCountry: string;
|
|
233
|
+
}> = [];
|
|
234
|
+
for (const event of args.events) {
|
|
235
|
+
const key = `${eventBatchKey(event.sessionId, eventBucketStart(event.timestamp))}:${event.eventId}`;
|
|
236
|
+
if (
|
|
237
|
+
seenEvents.has(key) ||
|
|
238
|
+
!claimed.has(event.sessionId) ||
|
|
239
|
+
!persistedEvents
|
|
240
|
+
.get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
|
|
241
|
+
?.has(event.eventId)
|
|
242
|
+
) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
seenEvents.add(key);
|
|
246
|
+
const priorCountry = persistedEvents
|
|
247
|
+
.get(
|
|
248
|
+
eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
|
|
249
|
+
)
|
|
250
|
+
?.get(event.eventId);
|
|
251
|
+
if (priorCountry === undefined) continue;
|
|
252
|
+
correctionEvents.push({
|
|
253
|
+
sessionId: event.sessionId,
|
|
254
|
+
timestamp: event.timestamp,
|
|
255
|
+
priorCountry,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return { sessionIds, events: correctionEvents };
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
export const applyEnrichment = internalMutation({
|
|
263
|
+
args: {
|
|
264
|
+
siteId: v.id("sites"),
|
|
265
|
+
sessionIds: v.array(v.string()),
|
|
266
|
+
events: v.array(geoAggregateEventValidator),
|
|
267
|
+
context: geoContextValidator,
|
|
268
|
+
},
|
|
269
|
+
returns: v.number(),
|
|
270
|
+
handler: async (ctx, args) => {
|
|
271
|
+
if (
|
|
272
|
+
args.sessionIds.length === 0 ||
|
|
273
|
+
args.sessionIds.length > MAX_ENRICHMENT_SESSIONS ||
|
|
274
|
+
args.events.length > MAX_BATCH_EVENTS
|
|
275
|
+
) {
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
const context = sanitizeContext(args.context);
|
|
279
|
+
const sessionCountries = new Map<string, string>();
|
|
280
|
+
let updated = 0;
|
|
281
|
+
for (const sessionId of new Set(args.sessionIds)) {
|
|
282
|
+
const session = await ctx.db
|
|
283
|
+
.query("sessions")
|
|
284
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
285
|
+
range.eq("siteId", args.siteId).eq("sessionId", sessionId),
|
|
286
|
+
)
|
|
287
|
+
.unique();
|
|
288
|
+
if (!session || session.geoLookupAttemptedAt === undefined) continue;
|
|
289
|
+
const sessionCountry = session.country ?? context.country;
|
|
290
|
+
if (sessionCountry) sessionCountries.set(sessionId, sessionCountry);
|
|
291
|
+
const sessionGeo = missingGeo(session, context);
|
|
292
|
+
if (Object.keys(sessionGeo).length > 0) {
|
|
293
|
+
await ctx.db.patch("sessions", session._id, sessionGeo);
|
|
294
|
+
}
|
|
295
|
+
const liveSession = await ctx.db
|
|
296
|
+
.query("liveSessions")
|
|
297
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
298
|
+
range.eq("siteId", args.siteId).eq("sessionId", sessionId),
|
|
299
|
+
)
|
|
300
|
+
.unique();
|
|
301
|
+
if (liveSession) {
|
|
302
|
+
const liveGeo = missingGeo(liveSession, context);
|
|
303
|
+
if (Object.keys(liveGeo).length > 0) {
|
|
304
|
+
await ctx.db.patch("liveSessions", liveSession._id, liveGeo);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
updated += 1;
|
|
308
|
+
}
|
|
309
|
+
if (sessionCountries.size > 0) {
|
|
310
|
+
await correctCountryAggregates(
|
|
311
|
+
ctx,
|
|
312
|
+
args.siteId,
|
|
313
|
+
args.events,
|
|
314
|
+
sessionCountries,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return updated;
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
export function geoIpDailyLimit(value: string | undefined): number {
|
|
322
|
+
if (value === undefined) return DEFAULT_GEOIP_DAILY_LIMIT;
|
|
323
|
+
const parsed = Number(value);
|
|
324
|
+
return Number.isSafeInteger(parsed) &&
|
|
325
|
+
parsed >= 0 &&
|
|
326
|
+
parsed <= MAX_GEOIP_DAILY_LIMIT
|
|
327
|
+
? parsed
|
|
328
|
+
: DEFAULT_GEOIP_DAILY_LIMIT;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function geoIpStatus(
|
|
332
|
+
provider: GeoIpProvider | undefined,
|
|
333
|
+
token: string | undefined,
|
|
334
|
+
): GeoIpStatus {
|
|
335
|
+
if (!provider) return "disabled";
|
|
336
|
+
if (provider === "ipinfo" && !token) return "missingToken";
|
|
337
|
+
return "ready";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function readRequestMetadata(ctx: {
|
|
341
|
+
meta: {
|
|
342
|
+
getRequestMetadata(): Promise<{
|
|
343
|
+
ip: string | null;
|
|
344
|
+
userAgent: string | null;
|
|
345
|
+
}>;
|
|
346
|
+
};
|
|
347
|
+
}): Promise<{ ip: string | null; userAgent: string | null }> {
|
|
348
|
+
try {
|
|
349
|
+
const metadata = await ctx.meta.getRequestMetadata();
|
|
350
|
+
return { ip: metadata.ip, userAgent: metadata.userAgent };
|
|
351
|
+
} catch {
|
|
352
|
+
// Older self-hosted runtimes and convex-test may not implement this syscall.
|
|
353
|
+
return { ip: null, userAgent: null };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function resolveGeoIp(
|
|
358
|
+
ip: string,
|
|
359
|
+
provider: GeoIpProvider,
|
|
360
|
+
token: string | undefined,
|
|
361
|
+
fetcher: Fetcher = fetch,
|
|
362
|
+
): Promise<GeoContext | undefined> {
|
|
363
|
+
if (geoIpStatus(provider, token) !== "ready" || classifyIp(ip) !== "public") {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
const controller = new AbortController();
|
|
367
|
+
const timeout = setTimeout(() => controller.abort(), GEOIP_TIMEOUT_MS);
|
|
368
|
+
try {
|
|
369
|
+
const response = await fetcher(
|
|
370
|
+
geoIpRequest(ip, provider, token),
|
|
371
|
+
provider === "ipinfo"
|
|
372
|
+
? {
|
|
373
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
374
|
+
signal: controller.signal,
|
|
375
|
+
}
|
|
376
|
+
: { signal: controller.signal },
|
|
377
|
+
);
|
|
378
|
+
if (!response.ok) return undefined;
|
|
379
|
+
const value: unknown = await response.json();
|
|
380
|
+
return provider === "ipinfo"
|
|
381
|
+
? parseIpinfoResponse(value)
|
|
382
|
+
: parseIpwhoisResponse(value);
|
|
383
|
+
} catch {
|
|
384
|
+
return undefined;
|
|
385
|
+
} finally {
|
|
386
|
+
clearTimeout(timeout);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function classifyIp(ip: string | null): IpKind {
|
|
391
|
+
if (ip === null || ip.trim() === "") return "unavailable";
|
|
392
|
+
const normalized = ip.trim().toLowerCase();
|
|
393
|
+
const ipv4 = /^\d+\.\d+\.\d+\.\d+$/.test(normalized) ? normalized : undefined;
|
|
394
|
+
if (ipv4) return classifyIpv4(ipv4);
|
|
395
|
+
if (!normalized.includes(":")) return "invalid";
|
|
396
|
+
return classifyIpv6(normalized);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function resolveClientIp(
|
|
400
|
+
nativeIp: string | null,
|
|
401
|
+
headers: Headers,
|
|
402
|
+
trustProxy = false,
|
|
403
|
+
): { ip: string | null; source: ClientIpSource } {
|
|
404
|
+
if (classifyIp(nativeIp) === "public") {
|
|
405
|
+
return { ip: nativeIp, source: "native" };
|
|
406
|
+
}
|
|
407
|
+
// Forwarded headers require an explicit operator trust decision and a
|
|
408
|
+
// private immediate peer so direct client requests cannot spoof the IP.
|
|
409
|
+
if (!trustProxy || classifyIp(nativeIp) !== "private") {
|
|
410
|
+
return { ip: null, source: "unavailable" };
|
|
411
|
+
}
|
|
412
|
+
const candidates = [
|
|
413
|
+
...(headers.get("x-forwarded-for")?.split(",").reverse() ?? []),
|
|
414
|
+
headers.get("x-real-ip"),
|
|
415
|
+
headers.get("cf-connecting-ip"),
|
|
416
|
+
];
|
|
417
|
+
for (const candidate of candidates) {
|
|
418
|
+
const ip = normalizeForwardedIp(candidate);
|
|
419
|
+
if (ip && classifyIp(ip) === "public") {
|
|
420
|
+
return { ip, source: "forwarded" };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { ip: null, source: "unavailable" };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function isTrustedProxyRequest(
|
|
427
|
+
nativeIp: string | null,
|
|
428
|
+
trustProxy: boolean,
|
|
429
|
+
): boolean {
|
|
430
|
+
return trustProxy && classifyIp(nativeIp) === "private";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function classifyIpv4(ip: string): Exclude<IpKind, "unavailable"> {
|
|
434
|
+
const octets = ip.split(".").map(Number);
|
|
435
|
+
if (
|
|
436
|
+
octets.length !== 4 ||
|
|
437
|
+
octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
|
438
|
+
) {
|
|
439
|
+
return "invalid";
|
|
440
|
+
}
|
|
441
|
+
const [a, b, c] = octets as [number, number, number, number];
|
|
442
|
+
if (
|
|
443
|
+
a === 0 ||
|
|
444
|
+
a === 10 ||
|
|
445
|
+
a === 127 ||
|
|
446
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
447
|
+
(a === 169 && b === 254) ||
|
|
448
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
449
|
+
(a === 192 && b === 0 && c === 0) ||
|
|
450
|
+
(a === 192 && b === 0 && c === 2) ||
|
|
451
|
+
(a === 192 && b === 168) ||
|
|
452
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
453
|
+
(a === 198 && b === 51 && c === 100) ||
|
|
454
|
+
(a === 203 && b === 0 && c === 113) ||
|
|
455
|
+
a >= 224
|
|
456
|
+
) {
|
|
457
|
+
return "private";
|
|
458
|
+
}
|
|
459
|
+
return "public";
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function classifyIpv6(ip: string): Exclude<IpKind, "unavailable"> {
|
|
463
|
+
const groups = parseIpv6(ip);
|
|
464
|
+
if (!groups) return "invalid";
|
|
465
|
+
if (
|
|
466
|
+
groups.every((group) => group === 0) ||
|
|
467
|
+
(groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) ||
|
|
468
|
+
(groups[0]! & 0xfe00) === 0xfc00 ||
|
|
469
|
+
(groups[0]! & 0xffc0) === 0xfe80 ||
|
|
470
|
+
(groups[0]! & 0xffc0) === 0xfec0 ||
|
|
471
|
+
(groups[0]! & 0xff00) === 0xff00 ||
|
|
472
|
+
(groups[0] === 0x0100 && groups.slice(1, 4).every((group) => group === 0)) ||
|
|
473
|
+
(groups[0] === 0x0064 && groups[1] === 0xff9b && groups[2] === 1) ||
|
|
474
|
+
(groups[0] === 0x2001 && (groups[1]! & 0xfe00) === 0) ||
|
|
475
|
+
(groups[0] === 0x2001 && groups[1] === 0x0db8) ||
|
|
476
|
+
groups[0] === 0x2002 ||
|
|
477
|
+
(groups[0]! & 0xfff0) === 0x3ff0
|
|
478
|
+
) {
|
|
479
|
+
return "private";
|
|
480
|
+
}
|
|
481
|
+
if (groups.slice(0, 6).every((group) => group === 0)) {
|
|
482
|
+
return "private";
|
|
483
|
+
}
|
|
484
|
+
if (
|
|
485
|
+
groups.slice(0, 5).every((group) => group === 0) &&
|
|
486
|
+
groups[5] === 0xffff
|
|
487
|
+
) {
|
|
488
|
+
const ipv4 = `${groups[6]! >> 8}.${groups[6]! & 255}.${groups[7]! >> 8}.${groups[7]! & 255}`;
|
|
489
|
+
return classifyIpv4(ipv4);
|
|
490
|
+
}
|
|
491
|
+
// Current globally routable unicast allocations are within 2000::/3.
|
|
492
|
+
// Unknown/special space fails closed so it is never sent to a provider.
|
|
493
|
+
return (groups[0]! & 0xe000) === 0x2000 ? "public" : "private";
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function parseIpv6(ip: string): number[] | undefined {
|
|
497
|
+
let normalized = ip;
|
|
498
|
+
const dottedTail = normalized.match(/(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
|
499
|
+
if (dottedTail) {
|
|
500
|
+
if (classifyIpv4(dottedTail) === "invalid") return undefined;
|
|
501
|
+
const octets = dottedTail.split(".").map(Number);
|
|
502
|
+
normalized =
|
|
503
|
+
normalized.slice(0, -dottedTail.length) +
|
|
504
|
+
`${((octets[0]! << 8) | octets[1]!).toString(16)}:${((octets[2]! << 8) | octets[3]!).toString(16)}`;
|
|
505
|
+
}
|
|
506
|
+
const halves = normalized.split("::");
|
|
507
|
+
if (halves.length > 2) return undefined;
|
|
508
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
509
|
+
const right = halves[1] ? halves[1].split(":") : [];
|
|
510
|
+
if (
|
|
511
|
+
[...left, ...right].some((group) => !/^[0-9a-f]{1,4}$/.test(group)) ||
|
|
512
|
+
(halves.length === 1 && left.length !== 8) ||
|
|
513
|
+
(halves.length === 2 && left.length + right.length >= 8)
|
|
514
|
+
) {
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
const fill =
|
|
518
|
+
halves.length === 2
|
|
519
|
+
? Array<number>(8 - left.length - right.length).fill(0)
|
|
520
|
+
: [];
|
|
521
|
+
return [...left.map(hexGroup), ...fill, ...right.map(hexGroup)];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function hexGroup(value: string): number {
|
|
525
|
+
return Number.parseInt(value, 16);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function normalizeForwardedIp(value: string | null): string | undefined {
|
|
529
|
+
if (!value) return undefined;
|
|
530
|
+
let normalized = value.trim().replace(/^"|"$/g, "");
|
|
531
|
+
if (normalized.startsWith("[")) {
|
|
532
|
+
const end = normalized.indexOf("]");
|
|
533
|
+
return end > 0 ? normalized.slice(1, end) : undefined;
|
|
534
|
+
}
|
|
535
|
+
if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(normalized)) {
|
|
536
|
+
normalized = normalized.slice(0, normalized.lastIndexOf(":"));
|
|
537
|
+
}
|
|
538
|
+
return normalized || undefined;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function correctCountryAggregates(
|
|
542
|
+
ctx: MutationCtx,
|
|
543
|
+
siteId: Id<"sites">,
|
|
544
|
+
events: Array<{
|
|
545
|
+
sessionId: string;
|
|
546
|
+
timestamp: number;
|
|
547
|
+
priorCountry: string;
|
|
548
|
+
}>,
|
|
549
|
+
sessionCountries: Map<string, string>,
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
const corrections = new Map<
|
|
552
|
+
string,
|
|
553
|
+
{
|
|
554
|
+
granularity: "hour" | "day";
|
|
555
|
+
bucketStart: number;
|
|
556
|
+
shard: number;
|
|
557
|
+
moves: Map<string, { from: string; to: string; count: number }>;
|
|
558
|
+
}
|
|
559
|
+
>();
|
|
560
|
+
for (const event of events) {
|
|
561
|
+
const country = sessionCountries.get(event.sessionId);
|
|
562
|
+
if (!country || country === event.priorCountry) continue;
|
|
563
|
+
for (const granularity of ["hour", "day"] as const) {
|
|
564
|
+
const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
|
|
565
|
+
const bucketStart = Math.floor(event.timestamp / interval) * interval;
|
|
566
|
+
const shard = stableHash(event.sessionId) %
|
|
567
|
+
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
568
|
+
const key = `${granularity}:${bucketStart}:${shard}`;
|
|
569
|
+
const correction = corrections.get(key) ?? {
|
|
570
|
+
granularity,
|
|
571
|
+
bucketStart,
|
|
572
|
+
shard,
|
|
573
|
+
moves: new Map<string, { from: string; to: string; count: number }>(),
|
|
574
|
+
};
|
|
575
|
+
const moveKey = `${event.priorCountry}:${country}`;
|
|
576
|
+
const move = correction.moves.get(moveKey) ?? {
|
|
577
|
+
from: event.priorCountry,
|
|
578
|
+
to: country,
|
|
579
|
+
count: 0,
|
|
580
|
+
};
|
|
581
|
+
move.count += 1;
|
|
582
|
+
correction.moves.set(moveKey, move);
|
|
583
|
+
corrections.set(key, correction);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
for (const correction of corrections.values()) {
|
|
587
|
+
const bucket = await ctx.db
|
|
588
|
+
.query("aggregateBuckets")
|
|
589
|
+
.withIndex(
|
|
590
|
+
"by_siteId_and_granularity_and_bucketStart_and_shard",
|
|
591
|
+
(range) =>
|
|
592
|
+
range
|
|
593
|
+
.eq("siteId", siteId)
|
|
594
|
+
.eq("granularity", correction.granularity)
|
|
595
|
+
.eq("bucketStart", correction.bucketStart)
|
|
596
|
+
.eq("shard", correction.shard),
|
|
597
|
+
)
|
|
598
|
+
.unique();
|
|
599
|
+
if (!bucket) continue;
|
|
600
|
+
let dimensions = bucket.dimensions;
|
|
601
|
+
let changed = false;
|
|
602
|
+
for (const move of correction.moves.values()) {
|
|
603
|
+
const corrected = moveCountryCount(
|
|
604
|
+
dimensions,
|
|
605
|
+
move.from,
|
|
606
|
+
move.to,
|
|
607
|
+
move.count,
|
|
608
|
+
);
|
|
609
|
+
if (corrected) {
|
|
610
|
+
dimensions = corrected;
|
|
611
|
+
changed = true;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (changed) {
|
|
615
|
+
await ctx.db.patch("aggregateBuckets", bucket._id, { dimensions });
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function moveCountryCount(
|
|
621
|
+
current: Doc<"aggregateBuckets">["dimensions"],
|
|
622
|
+
from: string,
|
|
623
|
+
to: string,
|
|
624
|
+
requested: number,
|
|
625
|
+
): Doc<"aggregateBuckets">["dimensions"] | undefined {
|
|
626
|
+
const dimensions = current.map((slot) => ({ ...slot }));
|
|
627
|
+
let sourceIndex = dimensions.findIndex(
|
|
628
|
+
(slot) => slot.type === "country" && slot.value === from,
|
|
629
|
+
);
|
|
630
|
+
if (sourceIndex < 0) {
|
|
631
|
+
sourceIndex = dimensions.findIndex(
|
|
632
|
+
(slot) => slot.type === "country" && slot.value === "(other)",
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
if (sourceIndex < 0) return undefined;
|
|
636
|
+
const source = dimensions[sourceIndex]!;
|
|
637
|
+
const moved = Math.min(source.count, requested);
|
|
638
|
+
if (moved <= 0) return undefined;
|
|
639
|
+
source.count -= moved;
|
|
640
|
+
if (source.count === 0 && source.revenueCents === 0) {
|
|
641
|
+
dimensions.splice(sourceIndex, 1);
|
|
642
|
+
}
|
|
643
|
+
const target = dimensions.find(
|
|
644
|
+
(slot) => slot.type === "country" && slot.value === to,
|
|
645
|
+
);
|
|
646
|
+
if (target) {
|
|
647
|
+
target.count += moved;
|
|
648
|
+
return dimensions;
|
|
649
|
+
}
|
|
650
|
+
const countrySlots = dimensions.filter((slot) => slot.type === "country");
|
|
651
|
+
if (countrySlots.length < DIMENSION_SLOTS - 1) {
|
|
652
|
+
const occupied = new Set(countrySlots.map((slot) => slot.slot));
|
|
653
|
+
let slot = stableHash(to) % DIMENSION_SLOTS;
|
|
654
|
+
while (occupied.has(slot)) slot = (slot + 1) % DIMENSION_SLOTS;
|
|
655
|
+
dimensions.push({
|
|
656
|
+
type: "country",
|
|
657
|
+
slot,
|
|
658
|
+
value: to,
|
|
659
|
+
count: moved,
|
|
660
|
+
revenueCents: 0,
|
|
661
|
+
});
|
|
662
|
+
return dimensions;
|
|
663
|
+
}
|
|
664
|
+
const overflow = countrySlots.find((slot) => slot.value === "(other)");
|
|
665
|
+
if (overflow) {
|
|
666
|
+
overflow.count += moved;
|
|
667
|
+
return dimensions;
|
|
668
|
+
}
|
|
669
|
+
const occupied = new Set(countrySlots.map((slot) => slot.slot));
|
|
670
|
+
if (countrySlots.length < DIMENSION_SLOTS) {
|
|
671
|
+
let slot = stableHash("(other)") % DIMENSION_SLOTS;
|
|
672
|
+
while (occupied.has(slot)) slot = (slot + 1) % DIMENSION_SLOTS;
|
|
673
|
+
dimensions.push({
|
|
674
|
+
type: "country",
|
|
675
|
+
slot,
|
|
676
|
+
value: "(other)",
|
|
677
|
+
count: moved,
|
|
678
|
+
revenueCents: 0,
|
|
679
|
+
});
|
|
680
|
+
return dimensions;
|
|
681
|
+
}
|
|
682
|
+
let replacement = countrySlots[0]!;
|
|
683
|
+
for (const candidate of countrySlots.slice(1)) {
|
|
684
|
+
if (candidate.count < replacement.count) replacement = candidate;
|
|
685
|
+
}
|
|
686
|
+
replacement.value = "(other)";
|
|
687
|
+
replacement.count += moved;
|
|
688
|
+
return dimensions;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function missingGeo(existing: GeoContext, context: GeoContext): GeoContext {
|
|
692
|
+
const coordinates =
|
|
693
|
+
(existing.latitude === undefined || existing.longitude === undefined) &&
|
|
694
|
+
context.latitude !== undefined &&
|
|
695
|
+
context.longitude !== undefined
|
|
696
|
+
? { latitude: context.latitude, longitude: context.longitude }
|
|
697
|
+
: {};
|
|
698
|
+
return {
|
|
699
|
+
...(existing.country === undefined && context.country !== undefined
|
|
700
|
+
? { country: context.country }
|
|
701
|
+
: {}),
|
|
702
|
+
...(existing.city === undefined && context.city !== undefined
|
|
703
|
+
? { city: context.city }
|
|
704
|
+
: {}),
|
|
705
|
+
...coordinates,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function geoIpRequest(
|
|
710
|
+
ip: string,
|
|
711
|
+
provider: GeoIpProvider,
|
|
712
|
+
token: string | undefined,
|
|
713
|
+
): URL {
|
|
714
|
+
const encodedIp = encodeURIComponent(ip);
|
|
715
|
+
if (provider === "ipinfo") {
|
|
716
|
+
return new URL(`https://api.ipinfo.io/lookup/${encodedIp}`);
|
|
717
|
+
}
|
|
718
|
+
const url = new URL(
|
|
719
|
+
token
|
|
720
|
+
? `https://ipwhois.pro/${encodedIp}`
|
|
721
|
+
: `https://ipwho.is/${encodedIp}`,
|
|
722
|
+
);
|
|
723
|
+
url.searchParams.set(
|
|
724
|
+
"fields",
|
|
725
|
+
"success,country_code,city,latitude,longitude",
|
|
726
|
+
);
|
|
727
|
+
if (token) url.searchParams.set("key", token);
|
|
728
|
+
return url;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function parseIpinfoResponse(value: unknown): GeoContext | undefined {
|
|
732
|
+
if (!isPlainRecord(value) || !isPlainRecord(value.geo)) return undefined;
|
|
733
|
+
return geoContext({
|
|
734
|
+
country: value.geo.country_code,
|
|
735
|
+
city: value.geo.city,
|
|
736
|
+
latitude: value.geo.latitude,
|
|
737
|
+
longitude: value.geo.longitude,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function parseIpwhoisResponse(value: unknown): GeoContext | undefined {
|
|
742
|
+
if (!isPlainRecord(value) || value.success === false) return undefined;
|
|
743
|
+
return geoContext({
|
|
744
|
+
country: value.country_code,
|
|
745
|
+
city: value.city,
|
|
746
|
+
latitude: value.latitude,
|
|
747
|
+
longitude: value.longitude,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function geoContext(value: Record<string, unknown>): GeoContext | undefined {
|
|
752
|
+
const country = typeof value.country === "string" ? value.country : undefined;
|
|
753
|
+
const city = typeof value.city === "string" ? value.city : undefined;
|
|
754
|
+
const latitude = finiteNumber(value.latitude);
|
|
755
|
+
const longitude = finiteNumber(value.longitude);
|
|
756
|
+
const coordinates =
|
|
757
|
+
latitude !== undefined &&
|
|
758
|
+
longitude !== undefined &&
|
|
759
|
+
latitude >= -90 &&
|
|
760
|
+
latitude <= 90 &&
|
|
761
|
+
longitude >= -180 &&
|
|
762
|
+
longitude <= 180
|
|
763
|
+
? { latitude, longitude }
|
|
764
|
+
: {};
|
|
765
|
+
if (!country && !city && Object.keys(coordinates).length === 0) {
|
|
766
|
+
return undefined;
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
...(country ? { country } : {}),
|
|
770
|
+
...(city ? { city } : {}),
|
|
771
|
+
...coordinates,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
776
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
777
|
+
? value
|
|
778
|
+
: undefined;
|
|
779
|
+
}
|