@iann29/rastro 0.1.0-alpha.7 → 0.1.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +34 -16
  2. package/agent/integration.md +14 -6
  3. package/agent/manifest.json +9 -7
  4. package/agent/manifest.schema.json +8 -5
  5. package/dist/client/federation.d.ts +13 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +4 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +412 -10
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +55 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +68 -4
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/constants.d.ts +4 -0
  16. package/dist/component/constants.d.ts.map +1 -1
  17. package/dist/component/constants.js +11 -0
  18. package/dist/component/constants.js.map +1 -1
  19. package/dist/component/coverage.d.ts +1 -0
  20. package/dist/component/coverage.d.ts.map +1 -1
  21. package/dist/component/coverage.js +13 -2
  22. package/dist/component/coverage.js.map +1 -1
  23. package/dist/component/http.d.ts.map +1 -1
  24. package/dist/component/http.js +1 -0
  25. package/dist/component/http.js.map +1 -1
  26. package/dist/component/ingest.d.ts.map +1 -1
  27. package/dist/component/ingest.js +144 -2
  28. package/dist/component/ingest.js.map +1 -1
  29. package/dist/component/reports.d.ts +75 -1
  30. package/dist/component/reports.d.ts.map +1 -1
  31. package/dist/component/reports.js +275 -3
  32. package/dist/component/reports.js.map +1 -1
  33. package/dist/component/retention.d.ts +4 -4
  34. package/dist/component/retention.d.ts.map +1 -1
  35. package/dist/component/retention.js +12 -2
  36. package/dist/component/retention.js.map +1 -1
  37. package/dist/component/sanitize.d.ts +9 -0
  38. package/dist/component/sanitize.d.ts.map +1 -1
  39. package/dist/component/sanitize.js +25 -0
  40. package/dist/component/sanitize.js.map +1 -1
  41. package/dist/component/schema.d.ts +65 -3
  42. package/dist/component/schema.js +25 -0
  43. package/dist/component/schema.js.map +1 -1
  44. package/docs/federation-setup.md +2 -0
  45. package/docs/federation.md +4 -0
  46. package/docs/upgrading.md +53 -10
  47. package/llms.txt +2 -0
  48. package/package.json +1 -1
  49. package/src/component/_generated/component.ts +55 -1
  50. package/src/component/constants.ts +11 -0
  51. package/src/component/coverage.ts +12 -1
  52. package/src/component/http.ts +1 -0
  53. package/src/component/ingest.ts +227 -0
  54. package/src/component/reports.ts +364 -2
  55. package/src/component/retention.ts +16 -1
  56. package/src/component/sanitize.ts +31 -0
  57. package/src/component/schema.ts +26 -0
@@ -1,7 +1,7 @@
1
1
  import { paginationOptsValidator, paginationResultValidator, } from "convex/server";
2
2
  import { v } from "convex/values";
3
3
  import { query } from "./_generated/server.js";
4
- import { AGGREGATE_SHARDS, DAY_MS, FEATURE_REPORT_BYTE_RESERVE, FEATURE_REPORT_DOCUMENT_RESERVE, HOUR_MS, HOURLY_AGGREGATE_SHARDS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_FEATURE_ROLLUP_DAYS, MAX_FEATURE_ROLLUP_ROWS, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS, MAX_OVERVIEW_AGGREGATE_ROWS, MAX_REPORT_BUCKETS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, MAX_VITALS_REPORT_ROWS, REPORT_ROLLUP_SHARDS, VITALS_REPORT_PAGE_LIMIT, } from "./constants.js";
4
+ import { AGGREGATE_SHARDS, DAY_MS, FEATURE_REPORT_BYTE_RESERVE, FEATURE_REPORT_DOCUMENT_RESERVE, HOUR_MS, HOURLY_AGGREGATE_SHARDS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_FEATURE_ROLLUP_DAYS, MAX_FEATURE_ROLLUP_ROWS, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS, MAX_OVERVIEW_AGGREGATE_ROWS, MAX_REPORT_BUCKETS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, MAX_SITE_MAP_ROUTES_PER_DAY, MAX_SITE_MAP_ROWS, MAX_VITALS_REPORT_ROWS, REPORT_ROLLUP_SHARDS, ROUTE_TRANSITION_SLOTS, SITE_MAP_OTHER_ROUTE, VITALS_REPORT_PAGE_LIMIT, } from "./constants.js";
5
5
  import { createVisitorSketch, estimateVisitors, unionVisitorSketches, } from "./cardinality.js";
6
6
  import { fail } from "./errors.js";
7
7
  import { compareJourneyEvents, hydrateEventContexts, loadSessionJourneyEvents, loadVisitorJourneyEvents, } from "./eventStore.js";
@@ -37,6 +37,8 @@ const liveSessionDocumentValidator = v.object({
37
37
  device: v.string(),
38
38
  pageviewCount: v.number(),
39
39
  eventCount: v.number(),
40
+ previousPath: v.optional(v.string()),
41
+ movedAt: v.optional(v.number()),
40
42
  });
41
43
  const conversionDocumentValidator = v.object({
42
44
  _id: v.id("conversions"),
@@ -334,9 +336,241 @@ export const liveVisitors = query({
334
336
  device: row.device,
335
337
  pageviewCount: row.pageviewCount,
336
338
  eventCount: row.eventCount,
339
+ ...(row.previousPath ? { previousPath: row.previousPath } : {}),
340
+ ...(row.movedAt !== undefined ? { movedAt: row.movedAt } : {}),
337
341
  }));
338
342
  },
339
343
  });
344
+ const siteMapRouteValidator = v.object({
345
+ route: v.string(),
346
+ pageviews: v.number(),
347
+ visitors: v.number(),
348
+ entries: v.number(),
349
+ exits: v.number(),
350
+ bounces: v.number(),
351
+ transitionsIn: v.number(),
352
+ transitionsOut: v.number(),
353
+ dwellMs: v.number(),
354
+ dwellSamples: v.number(),
355
+ daily: v.array(v.object({ bucketStart: v.number(), pageviews: v.number() })),
356
+ });
357
+ const siteMapTransitionValidator = v.object({
358
+ from: v.string(),
359
+ to: v.string(),
360
+ count: v.number(),
361
+ });
362
+ const siteMapCoverageDataset = "siteMapRollups";
363
+ /**
364
+ * Every route the sites served in the range and the transitions between
365
+ * them, read from the daily route rollups newest day first. Exits and
366
+ * bounces are derived rather than counted: a session enters once, leaves
367
+ * once, and every later pageview is a transition, so per route
368
+ * `exits = entries + transitionsIn - transitionsOut` and
369
+ * `bounces = entries - continued` hold without a session-end hook. The
370
+ * report never falls back to raw telemetry; days it could not cover are
371
+ * declared through `coverage` and `window`.
372
+ */
373
+ export const siteMap = query({
374
+ args: {
375
+ siteIds: v.array(v.id("sites")),
376
+ from: v.number(),
377
+ to: v.number(),
378
+ },
379
+ returns: v.object({
380
+ from: v.number(),
381
+ to: v.number(),
382
+ window: v.union(v.object({ from: v.number(), to: v.number() }), v.null()),
383
+ routes: v.array(siteMapRouteValidator),
384
+ transitions: v.array(siteMapTransitionValidator),
385
+ truncated: v.object({ routes: v.boolean(), transitions: v.boolean() }),
386
+ metadata: reportMetadataValidator.extend({
387
+ visitors: v.object({
388
+ basis: v.literal("estimatedUnique"),
389
+ exact: v.boolean(),
390
+ }),
391
+ limits: v.object({
392
+ routesPerDay: v.number(),
393
+ transitionSlots: v.number(),
394
+ rows: v.number(),
395
+ }),
396
+ }),
397
+ }),
398
+ handler: async (ctx, args) => {
399
+ const siteIds = validateSiteIds(args.siteIds);
400
+ validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
401
+ requireAlignedRange(args.from, args.to, DAY_MS, "site map");
402
+ const controls = await Promise.all(siteIds.map((siteId) => ctx.db
403
+ .query("analyticsCoverage")
404
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
405
+ .unique()));
406
+ const since = controls.every((control) => control?.siteMapSince !== undefined)
407
+ ? Math.max(...controls.map((control) => control.siteMapSince))
408
+ : null;
409
+ const retainedBefore = controls.reduce((latest, control) => {
410
+ const before = control?.retained.find((item) => item.dataset === siteMapCoverageDataset)?.before;
411
+ return before === undefined ? latest : Math.max(latest ?? 0, before);
412
+ }, null);
413
+ const metadata = (state) => ({
414
+ basis: "dailyRollup",
415
+ coverage: {
416
+ state,
417
+ generation: 1,
418
+ availableFrom: since,
419
+ availableThrough: since === null ? null : args.to,
420
+ retainedBefore,
421
+ },
422
+ range: rangeSemantics(args.from, args.to),
423
+ visitors: { basis: "estimatedUnique", exact: false },
424
+ limits: {
425
+ routesPerDay: MAX_SITE_MAP_ROUTES_PER_DAY,
426
+ transitionSlots: ROUTE_TRANSITION_SLOTS,
427
+ rows: MAX_SITE_MAP_ROWS,
428
+ },
429
+ });
430
+ if (since === null ||
431
+ args.to < since ||
432
+ (retainedBefore !== null && args.to < retainedBefore)) {
433
+ return {
434
+ from: args.from,
435
+ to: args.to,
436
+ window: null,
437
+ routes: [],
438
+ transitions: [],
439
+ truncated: { routes: false, transitions: false },
440
+ metadata: metadata("unavailable"),
441
+ };
442
+ }
443
+ const readFrom = Math.max(args.from, Math.floor(since / DAY_MS) * DAY_MS, retainedBefore ?? 0);
444
+ let budget = MAX_SITE_MAP_ROWS;
445
+ let completeFrom = readFrom;
446
+ const rows = [];
447
+ for (const siteId of siteIds) {
448
+ const read = await readRouteRollups(ctx, siteId, readFrom, args.to, budget);
449
+ budget -= read.rows.length;
450
+ completeFrom = Math.max(completeFrom, read.completeFrom);
451
+ rows.push(...read.rows);
452
+ }
453
+ const windowFrom = Math.max(args.from, completeFrom, since, retainedBefore ?? 0);
454
+ const routes = new Map();
455
+ const totalsFor = (route) => {
456
+ let current = routes.get(route);
457
+ if (!current) {
458
+ current = {
459
+ pageviews: 0,
460
+ entries: 0,
461
+ continued: 0,
462
+ dwellMs: 0,
463
+ dwellSamples: 0,
464
+ transitionsIn: 0,
465
+ transitionsOut: 0,
466
+ sketch: createVisitorSketch(),
467
+ daily: new Map(),
468
+ };
469
+ routes.set(route, current);
470
+ }
471
+ return current;
472
+ };
473
+ const edges = new Map();
474
+ for (const row of rows) {
475
+ if (row.bucketStart < completeFrom)
476
+ continue;
477
+ const current = totalsFor(row.route);
478
+ current.pageviews += row.pageviews;
479
+ current.entries += row.entries;
480
+ current.continued += row.continued;
481
+ current.dwellMs += row.dwellMs;
482
+ current.dwellSamples += row.dwellSamples;
483
+ current.sketch = unionVisitorSketches(current.sketch, row.visitorSketch);
484
+ current.daily.set(row.bucketStart, (current.daily.get(row.bucketStart) ?? 0) + row.pageviews);
485
+ for (const target of row.outgoing) {
486
+ const key = `${row.route}\u0000${target.route}`;
487
+ const edge = edges.get(key) ?? {
488
+ from: row.route,
489
+ to: target.route,
490
+ count: 0,
491
+ };
492
+ edge.count += target.count;
493
+ edges.set(key, edge);
494
+ current.transitionsOut += target.count;
495
+ }
496
+ }
497
+ // A target without a row of its own in this window — folded past the
498
+ // daily cap, or already retained — counts under "(other)".
499
+ const transitions = new Map();
500
+ for (const edge of edges.values()) {
501
+ const to = routes.has(edge.to) ? edge.to : SITE_MAP_OTHER_ROUTE;
502
+ const key = `${edge.from}\u0000${to}`;
503
+ const merged = transitions.get(key) ?? { from: edge.from, to, count: 0 };
504
+ merged.count += edge.count;
505
+ transitions.set(key, merged);
506
+ totalsFor(to).transitionsIn += edge.count;
507
+ }
508
+ const routeRows = [...routes.entries()]
509
+ .map(([route, totals]) => ({
510
+ route,
511
+ pageviews: totals.pageviews,
512
+ visitors: estimateVisitors(totals.sketch),
513
+ entries: totals.entries,
514
+ exits: Math.max(0, totals.entries + totals.transitionsIn - totals.transitionsOut),
515
+ bounces: Math.max(0, totals.entries - totals.continued),
516
+ transitionsIn: totals.transitionsIn,
517
+ transitionsOut: totals.transitionsOut,
518
+ dwellMs: totals.dwellMs,
519
+ dwellSamples: totals.dwellSamples,
520
+ daily: [...totals.daily.entries()]
521
+ .sort(([left], [right]) => left - right)
522
+ .map(([bucketStart, pageviews]) => ({ bucketStart, pageviews })),
523
+ }))
524
+ .sort((left, right) => right.pageviews - left.pageviews ||
525
+ left.route.localeCompare(right.route));
526
+ const transitionRows = [...transitions.values()].sort((left, right) => right.count - left.count ||
527
+ left.from.localeCompare(right.from) ||
528
+ left.to.localeCompare(right.to));
529
+ return {
530
+ from: args.from,
531
+ to: args.to,
532
+ window: completeFrom > args.to ? null : { from: windowFrom, to: args.to },
533
+ routes: routeRows,
534
+ transitions: transitionRows,
535
+ truncated: {
536
+ routes: routes.has(SITE_MAP_OTHER_ROUTE),
537
+ transitions: transitionRows.some((transition) => transition.from === SITE_MAP_OTHER_ROUTE ||
538
+ transition.to === SITE_MAP_OTHER_ROUTE),
539
+ },
540
+ metadata: metadata(windowFrom > args.from ? "partial" : "complete"),
541
+ };
542
+ },
543
+ });
544
+ /**
545
+ * Reads a site's route rollups newest day first until the row budget or the
546
+ * transaction's read headroom runs out, then drops the day it could only
547
+ * read in part so every day it returns is complete.
548
+ */
549
+ async function readRouteRollups(ctx, siteId, from, to, budget) {
550
+ const rows = [];
551
+ let cutAt = null;
552
+ const query = ctx.db
553
+ .query("routeDailyRollups")
554
+ .withIndex("by_siteId_and_bucketStart", (range) => range
555
+ .eq("siteId", siteId)
556
+ .gte("bucketStart", from)
557
+ .lte("bucketStart", to))
558
+ .order("desc");
559
+ for await (const row of query) {
560
+ if (rows.length >= budget ||
561
+ (rows.length % 256 === 255 && !(await hasFeatureReadHeadroom(ctx)))) {
562
+ cutAt = row.bucketStart;
563
+ break;
564
+ }
565
+ rows.push(row);
566
+ }
567
+ if (cutAt === null)
568
+ return { rows, completeFrom: from };
569
+ while (rows.length > 0 && rows[rows.length - 1].bucketStart === cutAt) {
570
+ rows.pop();
571
+ }
572
+ return { rows, completeFrom: cutAt + DAY_MS };
573
+ }
340
574
  export const listSessions = query({
341
575
  args: {
342
576
  siteId: v.id("sites"),
@@ -386,7 +620,7 @@ export const listSessions = query({
386
620
  .order("desc")
387
621
  .take(take)));
388
622
  }
389
- const publicRows = rows.map(({ geoLookupAttemptedAt: _attemptedAt, ...row }) => row);
623
+ const publicRows = rows.map(({ geoLookupAttemptedAt: _attemptedAt, lastPageviewAt: _lastPageviewAt, ...row }) => row);
390
624
  return keysetPaginationResult(publicRows, pagination, (row) => ({
391
625
  timestamp: row.lastSeenAt,
392
626
  creationTime: row._creationTime,
@@ -923,7 +1157,7 @@ export const vitalsReport = query({
923
1157
  };
924
1158
  },
925
1159
  });
926
- const coverageDatasetValidator = v.union(v.literal("overviewHour"), v.literal("overviewDay"), v.literal("events"), v.literal("sessions"), v.literal("conversions"), v.literal("goals"), v.literal("funnels"), v.literal("affiliates"), v.literal("vitals"));
1160
+ const coverageDatasetValidator = v.union(v.literal("overviewHour"), v.literal("overviewDay"), v.literal("events"), v.literal("sessions"), v.literal("conversions"), v.literal("goals"), v.literal("funnels"), v.literal("affiliates"), v.literal("vitals"), v.literal("siteMap"));
927
1161
  export const dataCoverage = query({
928
1162
  args: {
929
1163
  siteId: v.id("sites"),
@@ -951,6 +1185,7 @@ export const dataCoverage = query({
951
1185
  "funnels",
952
1186
  "affiliates",
953
1187
  "vitals",
1188
+ "siteMap",
954
1189
  ];
955
1190
  return {
956
1191
  siteId: args.siteId,
@@ -1046,6 +1281,11 @@ function requireFeatureRollupBudget(entities, days, dataset) {
1046
1281
  });
1047
1282
  }
1048
1283
  }
1284
+ async function hasFeatureReadHeadroom(ctx) {
1285
+ const metrics = await ctx.meta.getTransactionMetrics();
1286
+ return (metrics.bytesRead.remaining >= FEATURE_REPORT_BYTE_RESERVE &&
1287
+ metrics.documentsRead.remaining >= FEATURE_REPORT_DOCUMENT_RESERVE);
1288
+ }
1049
1289
  async function requireFeatureReadHeadroom(ctx, dataset) {
1050
1290
  const metrics = await ctx.meta.getTransactionMetrics();
1051
1291
  if (metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
@@ -1135,6 +1375,9 @@ async function assertSourceAvailable(ctx, siteIds, sources, from) {
1135
1375
  }
1136
1376
  }
1137
1377
  async function datasetCoverageForRange(ctx, siteId, dataset, from, to) {
1378
+ if (dataset === "siteMap") {
1379
+ return siteMapDatasetCoverage(ctx, siteId, from, to);
1380
+ }
1138
1381
  // Vitals have no legacy fallback source; the rollup either covers the range
1139
1382
  // or the coverage states say how much of it exists.
1140
1383
  if (dataset === "vitals") {
@@ -1166,6 +1409,35 @@ async function datasetCoverageForRange(ctx, siteId, dataset, from, to) {
1166
1409
  }))).filter((option) => option !== null);
1167
1410
  return options.sort((left, right) => coverageRank(right.state) - coverageRank(left.state))[0];
1168
1411
  }
1412
+ /**
1413
+ * The site map's coverage without reading its rows: it starts at the site's
1414
+ * `siteMapSince` and ends where retention cut it. The report itself may still
1415
+ * narrow the window when the range exceeds its row budget.
1416
+ */
1417
+ async function siteMapDatasetCoverage(ctx, siteId, from, to) {
1418
+ const control = await ctx.db
1419
+ .query("analyticsCoverage")
1420
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1421
+ .unique();
1422
+ const since = control?.siteMapSince ?? null;
1423
+ const retainedBefore = control?.retained.find((item) => item.dataset === siteMapCoverageDataset)
1424
+ ?.before ?? null;
1425
+ const unavailable = since === null ||
1426
+ to < since ||
1427
+ (retainedBefore !== null && to < retainedBefore);
1428
+ const state = unavailable
1429
+ ? "unavailable"
1430
+ : from < Math.max(since, retainedBefore ?? 0)
1431
+ ? "partial"
1432
+ : "complete";
1433
+ return {
1434
+ state,
1435
+ generation: 1,
1436
+ availableFrom: since,
1437
+ availableThrough: since === null ? null : to,
1438
+ retainedBefore,
1439
+ };
1440
+ }
1169
1441
  async function sourceSetRange(ctx, siteId, sources) {
1170
1442
  const ranges = (await Promise.all(sources.map((source) => sourceRange(ctx, siteId, source)))).filter((range) => range !== null);
1171
1443
  if (ranges.length === 0)