@beesolve/analytics-processor 0.2.6 → 0.4.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/dist/index.js CHANGED
@@ -482,6 +482,57 @@ class Breakdowns {
482
482
  function partitionKey2(props) {
483
483
  return `breakdown#${props.dimension}#${props.grain}#${props.periodKey}`;
484
484
  }
485
+ // packages/analytics-processor/src/config.ts
486
+ import { GetCommand as GetCommand2, UpdateCommand as UpdateCommand2 } from "@aws-sdk/lib-dynamodb";
487
+ import * as v3 from "valibot";
488
+ var configSchema = v3.object({
489
+ pk: v3.literal("system#config"),
490
+ sk: v3.literal("analytics"),
491
+ startKey: v3.string(),
492
+ startedAt: v3.string()
493
+ });
494
+ var configKey = { pk: "system#config", sk: "analytics" };
495
+ function isConditionalCheckFailure(error) {
496
+ if (error == null || typeof error !== "object")
497
+ return false;
498
+ const name = "name" in error ? error.name : undefined;
499
+ return name === "ConditionalCheckFailedException";
500
+ }
501
+
502
+ class Config {
503
+ props;
504
+ constructor(props) {
505
+ this.props = props;
506
+ }
507
+ get = async () => {
508
+ const { Item: item } = await this.props.dynamo.send(new GetCommand2({ TableName: this.props.tableName, Key: configKey }));
509
+ if (item == null)
510
+ return null;
511
+ return v3.parse(configSchema, item);
512
+ };
513
+ ensureStart = async (props) => {
514
+ try {
515
+ await this.props.dynamo.send(new UpdateCommand2({
516
+ TableName: this.props.tableName,
517
+ Key: configKey,
518
+ UpdateExpression: "SET #startKey = :hourKey, #startedAt = if_not_exists(#startedAt, :now)",
519
+ ConditionExpression: "attribute_not_exists(#startKey) OR :hourKey < #startKey",
520
+ ExpressionAttributeNames: {
521
+ "#startKey": "startKey",
522
+ "#startedAt": "startedAt"
523
+ },
524
+ ExpressionAttributeValues: {
525
+ ":hourKey": props.hourKey,
526
+ ":now": new Date().toISOString()
527
+ }
528
+ }));
529
+ } catch (error) {
530
+ if (isConditionalCheckFailure(error))
531
+ return;
532
+ throw error;
533
+ }
534
+ };
535
+ }
485
536
  // packages/analytics-processor/src/device.ts
486
537
  var botPattern = /bot|crawler|spider|crawl|slurp|mediapartners|facebookexternalhit|bingpreview|duckduckbot|baiduspider|yandex|semrush|ahrefs|headlesschrome|python-requests|curl|wget|monitor|pingdom|uptime/i;
487
538
  var mobilePattern = /mobi|android|iphone|ipad|ipod|iemobile|blackberry|opera mini|windows phone|webos/i;
@@ -562,11 +613,15 @@ async function processHour({
562
613
  byPath: query.byPath,
563
614
  byCountry
564
615
  });
565
- return sendTransacted({
616
+ const result = await sendTransacted({
566
617
  dynamo,
567
618
  leading: [markerPut, totalsUpdate],
568
619
  rest: breakdownUpdates
569
620
  });
621
+ if (result.status === "applied" && query.totals.hits > 0 && models.config != null) {
622
+ await models.config.ensureStart({ hourKey });
623
+ }
624
+ return result;
570
625
  }
571
626
  async function applyDailyRollup({
572
627
  dayKey,
@@ -781,24 +836,237 @@ function computeHourlyTtl(hourlyTtlDays) {
781
836
  function toBreakdownRow(item) {
782
837
  return { value: item.value, hits: item.hits, bytes: item.bytes };
783
838
  }
784
- // packages/analytics-processor/src/time.ts
839
+ // packages/analytics-processor/src/resolveLive.ts
840
+ var bucketStates = ["sealed", "partial", "pending"];
841
+ var emptyTotals = {
842
+ hits: 0,
843
+ success: 0,
844
+ errors: 0,
845
+ errors4xx: 0,
846
+ errors5xx: 0,
847
+ bytesSent: 0,
848
+ bytesReceived: 0
849
+ };
850
+ async function resolveTotalsLive({
851
+ models,
852
+ grain,
853
+ periodKey,
854
+ now
855
+ }) {
856
+ if (grain === "hour") {
857
+ const stored = await models.totals.getOne({ grain: "hour", timeKey: periodKey });
858
+ return stored == null ? emptyTotals : pickTotals(stored);
859
+ }
860
+ if (await isSealed({ models, grain, periodKey, now })) {
861
+ const stored = await models.totals.getOne({ grain, timeKey: periodKey });
862
+ if (stored != null)
863
+ return pickTotals(stored);
864
+ }
865
+ if (grain === "day") {
866
+ const lastHour = isCurrentPeriod({ grain: "day", periodKey, now }) ? now.getUTCHours() : 23;
867
+ const hours = await models.totals.queryWindow({
868
+ grain: "hour",
869
+ startKey: `${periodKey}-00`,
870
+ endKey: `${periodKey}-${pad2(lastHour)}`
871
+ });
872
+ return sumTotals(hours);
873
+ }
874
+ const storedDays = await models.totals.queryWindow({
875
+ grain: "day",
876
+ startKey: `${periodKey}-01`,
877
+ endKey: `${periodKey}-31`
878
+ });
879
+ const base = sumTotals(storedDays.filter((item) => !isCurrentPeriod({ grain: "day", periodKey: item.timeKey, now })));
880
+ const currentDayKey = currentChildKey({ parentGrain: "month", periodKey, now });
881
+ if (currentDayKey == null)
882
+ return base;
883
+ const currentDay = await resolveTotalsLive({
884
+ models,
885
+ grain: "day",
886
+ periodKey: currentDayKey,
887
+ now
888
+ });
889
+ return sumTotals([base, currentDay]);
890
+ }
891
+ async function resolveBreakdownLive({
892
+ models,
893
+ dimension,
894
+ grain,
895
+ periodKey,
896
+ now
897
+ }) {
898
+ if (grain === "hour") {
899
+ return toRows(await models.breakdowns.getForPeriod({ dimension, grain: "hour", periodKey }));
900
+ }
901
+ if (await isSealed({ models, grain, periodKey, now })) {
902
+ const stored = await models.breakdowns.getForPeriod({ dimension, grain, periodKey });
903
+ if (stored.length > 0)
904
+ return toRows(stored);
905
+ }
906
+ if (grain === "day") {
907
+ const hourKeys = hourKeysInProgress({ periodKey, now });
908
+ const hourRows = await Promise.all(hourKeys.map((hourKey) => models.breakdowns.getForPeriod({ dimension, grain: "hour", periodKey: hourKey })));
909
+ return sumBreakdown(hourRows.flat().map(toRow));
910
+ }
911
+ const storedDayKeys = pastDayKeys({ periodKey, now });
912
+ const storedDayRows = await Promise.all(storedDayKeys.map((dayKey) => models.breakdowns.getForPeriod({ dimension, grain: "day", periodKey: dayKey })));
913
+ const rows = storedDayRows.flat().map(toRow);
914
+ const currentDayKey = currentChildKey({ parentGrain: "month", periodKey, now });
915
+ if (currentDayKey != null) {
916
+ const currentDay = await resolveBreakdownLive({
917
+ models,
918
+ dimension,
919
+ grain: "day",
920
+ periodKey: currentDayKey,
921
+ now
922
+ });
923
+ rows.push(...currentDay);
924
+ }
925
+ return sumBreakdown(rows);
926
+ }
927
+ async function classifyBucketState({
928
+ models,
929
+ grain,
930
+ periodKey,
931
+ now,
932
+ hasData
933
+ }) {
934
+ if (await isSealed({ models, grain, periodKey, now }))
935
+ return "sealed";
936
+ if (hasData)
937
+ return "partial";
938
+ return "pending";
939
+ }
940
+ async function isSealed({
941
+ models,
942
+ grain,
943
+ periodKey,
944
+ now
945
+ }) {
946
+ if (!isPastPeriod({ grain, periodKey, now }))
947
+ return false;
948
+ return models.markers.isApplied({ grain, periodKey });
949
+ }
950
+ function isCurrentPeriod({
951
+ grain,
952
+ periodKey,
953
+ now
954
+ }) {
955
+ return periodKey === currentKeyFor({ grain, now });
956
+ }
957
+ function isPastPeriod({
958
+ grain,
959
+ periodKey,
960
+ now
961
+ }) {
962
+ return periodKey < currentKeyFor({ grain, now });
963
+ }
964
+ function currentKeyFor({ grain, now }) {
965
+ const year = pad4(now.getUTCFullYear());
966
+ const month = pad2(now.getUTCMonth() + 1);
967
+ const day = pad2(now.getUTCDate());
968
+ const hour = pad2(now.getUTCHours());
969
+ if (grain === "month")
970
+ return `${year}-${month}`;
971
+ if (grain === "day")
972
+ return `${year}-${month}-${day}`;
973
+ return `${year}-${month}-${day}-${hour}`;
974
+ }
975
+ function currentChildKey({
976
+ parentGrain,
977
+ periodKey,
978
+ now
979
+ }) {
980
+ if (!isCurrentPeriod({ grain: parentGrain, periodKey, now }))
981
+ return null;
982
+ return currentKeyFor({ grain: "day", now });
983
+ }
984
+ function hourKeysInProgress({
985
+ periodKey,
986
+ now
987
+ }) {
988
+ const lastHour = isCurrentPeriod({ grain: "day", periodKey, now }) ? now.getUTCHours() : 23;
989
+ const keys = [];
990
+ for (let hour = 0;hour <= lastHour; hour += 1) {
991
+ keys.push(`${periodKey}-${pad2(hour)}`);
992
+ }
993
+ return keys;
994
+ }
995
+ function pastDayKeys({
996
+ periodKey,
997
+ now
998
+ }) {
999
+ const isCurrentMonth = isCurrentPeriod({ grain: "month", periodKey, now });
1000
+ const lastDay = isCurrentMonth ? now.getUTCDate() - 1 : daysInMonth(periodKey);
1001
+ const keys = [];
1002
+ for (let day = 1;day <= lastDay; day += 1) {
1003
+ keys.push(`${periodKey}-${pad2(day)}`);
1004
+ }
1005
+ return keys;
1006
+ }
1007
+ function daysInMonth(monthKey) {
1008
+ const [year, month] = monthKey.split("-").map(Number);
1009
+ return new Date(Date.UTC(year ?? 1970, month ?? 1, 0)).getUTCDate();
1010
+ }
1011
+ function toRow(item) {
1012
+ return { value: item.value, hits: item.hits, bytes: item.bytes };
1013
+ }
1014
+ function toRows(items) {
1015
+ return items.map(toRow);
1016
+ }
1017
+ function pickTotals(item) {
1018
+ return {
1019
+ hits: item.hits,
1020
+ success: item.success,
1021
+ errors: item.errors,
1022
+ errors4xx: item.errors4xx,
1023
+ errors5xx: item.errors5xx,
1024
+ bytesSent: item.bytesSent,
1025
+ bytesReceived: item.bytesReceived
1026
+ };
1027
+ }
785
1028
  function pad2(value) {
786
1029
  return value.toString().padStart(2, "0");
787
1030
  }
788
1031
  function pad4(value) {
789
1032
  return value.toString().padStart(4, "0");
790
1033
  }
1034
+ // packages/analytics-processor/src/time.ts
1035
+ function pad22(value) {
1036
+ return value.toString().padStart(2, "0");
1037
+ }
1038
+ function pad42(value) {
1039
+ return value.toString().padStart(4, "0");
1040
+ }
791
1041
  function yearKey(date) {
792
- return pad4(date.getUTCFullYear());
1042
+ return pad42(date.getUTCFullYear());
793
1043
  }
794
1044
  function monthKey(date) {
795
- return `${pad4(date.getUTCFullYear())}-${pad2(date.getUTCMonth() + 1)}`;
1045
+ return `${pad42(date.getUTCFullYear())}-${pad22(date.getUTCMonth() + 1)}`;
796
1046
  }
797
1047
  function dayKey(date) {
798
- return `${monthKey(date)}-${pad2(date.getUTCDate())}`;
1048
+ return `${monthKey(date)}-${pad22(date.getUTCDate())}`;
799
1049
  }
800
1050
  function hourKey(date) {
801
- return `${dayKey(date)}-${pad2(date.getUTCHours())}`;
1051
+ return `${dayKey(date)}-${pad22(date.getUTCHours())}`;
1052
+ }
1053
+ function parsePeriodKey(periodKey) {
1054
+ const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(periodKey);
1055
+ if (match == null) {
1056
+ throw new Error(`Invalid period key: ${periodKey}`);
1057
+ }
1058
+ const [, year, month, day, hour] = match;
1059
+ return new Date(Date.UTC(Number(year), month == null ? 0 : Number(month) - 1, day == null ? 1 : Number(day), hour == null ? 0 : Number(hour)));
1060
+ }
1061
+ function grainForPeriodKey(periodKey) {
1062
+ const segments = periodKey.split("-").length;
1063
+ if (segments === 1)
1064
+ return "year";
1065
+ if (segments === 2)
1066
+ return "month";
1067
+ if (segments === 3)
1068
+ return "day";
1069
+ return "hour";
802
1070
  }
803
1071
  function maxWindowFor(grain) {
804
1072
  if (grain === "hour")
@@ -848,9 +1116,9 @@ function hoursBetween({ from, to }) {
848
1116
  return keys;
849
1117
  }
850
1118
  // packages/analytics-processor/src/totals.ts
851
- import { GetCommand as GetCommand2, UpdateCommand as UpdateCommand2 } from "@aws-sdk/lib-dynamodb";
1119
+ import { GetCommand as GetCommand3, UpdateCommand as UpdateCommand3 } from "@aws-sdk/lib-dynamodb";
852
1120
  import { queryAll as queryAll2 } from "@beesolve/dynamo-helpers";
853
- import * as v3 from "valibot";
1121
+ import * as v4 from "valibot";
854
1122
  var additiveFields = [
855
1123
  "hits",
856
1124
  "success",
@@ -866,23 +1134,23 @@ var hourlyDetailFields = [
866
1134
  "cacheHitRatio",
867
1135
  "uniqueIpCount"
868
1136
  ];
869
- var totalsSchema = v3.object({
870
- pk: v3.string(),
871
- sk: v3.string(),
872
- grain: v3.picklist(grains),
873
- timeKey: v3.string(),
874
- hits: v3.number(),
875
- success: v3.number(),
876
- errors: v3.number(),
877
- errors4xx: v3.number(),
878
- errors5xx: v3.number(),
879
- bytesSent: v3.number(),
880
- bytesReceived: v3.number(),
881
- p50TimeTaken: v3.optional(v3.number()),
882
- p95TimeTaken: v3.optional(v3.number()),
883
- cacheHitRatio: v3.optional(v3.number()),
884
- uniqueIpCount: v3.optional(v3.number()),
885
- ttl: v3.optional(v3.number())
1137
+ var totalsSchema = v4.object({
1138
+ pk: v4.string(),
1139
+ sk: v4.string(),
1140
+ grain: v4.picklist(grains),
1141
+ timeKey: v4.string(),
1142
+ hits: v4.number(),
1143
+ success: v4.number(),
1144
+ errors: v4.number(),
1145
+ errors4xx: v4.number(),
1146
+ errors5xx: v4.number(),
1147
+ bytesSent: v4.number(),
1148
+ bytesReceived: v4.number(),
1149
+ p50TimeTaken: v4.optional(v4.number()),
1150
+ p95TimeTaken: v4.optional(v4.number()),
1151
+ cacheHitRatio: v4.optional(v4.number()),
1152
+ uniqueIpCount: v4.optional(v4.number()),
1153
+ ttl: v4.optional(v4.number())
886
1154
  });
887
1155
 
888
1156
  class Totals {
@@ -892,11 +1160,11 @@ class Totals {
892
1160
  }
893
1161
  addHourly = async (props) => {
894
1162
  const update = this.buildHourlyUpdate(props);
895
- await this.props.dynamo.send(new UpdateCommand2(update.Update));
1163
+ await this.props.dynamo.send(new UpdateCommand3(update.Update));
896
1164
  };
897
1165
  addAggregate = async (props) => {
898
1166
  const update = this.buildAggregateUpdate(props);
899
- await this.props.dynamo.send(new UpdateCommand2(update.Update));
1167
+ await this.props.dynamo.send(new UpdateCommand3(update.Update));
900
1168
  };
901
1169
  buildHourlyUpdate = (props) => ({
902
1170
  Update: this.buildUpdateMembers({
@@ -930,7 +1198,7 @@ class Totals {
930
1198
  return items.map((item) => this.parseOne(item));
931
1199
  };
932
1200
  getOne = async (props) => {
933
- const { Item: item } = await this.props.dynamo.send(new GetCommand2({
1201
+ const { Item: item } = await this.props.dynamo.send(new GetCommand3({
934
1202
  TableName: this.props.tableName,
935
1203
  Key: { pk: partitionKey3(props.grain), sk: props.timeKey }
936
1204
  }));
@@ -975,9 +1243,9 @@ class Totals {
975
1243
  };
976
1244
  };
977
1245
  parseOne = (item) => {
978
- const result = v3.safeParse(totalsSchema, item);
1246
+ const result = v4.safeParse(totalsSchema, item);
979
1247
  if (!result.success) {
980
- console.error(v3.flatten(result.issues));
1248
+ console.error(v4.flatten(result.issues));
981
1249
  throw new Error("Malformed stored totals record");
982
1250
  }
983
1251
  return result.output;
@@ -989,26 +1257,34 @@ function partitionKey3(grain) {
989
1257
  export {
990
1258
  AppliedMarkers,
991
1259
  Breakdowns,
1260
+ Config,
992
1261
  Totals,
993
1262
  appliedMarkerSchema,
994
1263
  appliedSources,
995
1264
  applyDailyRollup,
996
1265
  applyMonthlyRollup,
997
1266
  breakdownSchema,
1267
+ bucketStates,
998
1268
  buildHourlyQuery,
999
1269
  chunkTransactItems,
1270
+ classifyBucketState,
1000
1271
  classifyDevice,
1001
1272
  columnMaps,
1273
+ configSchema,
1002
1274
  dayKey,
1003
1275
  dimensions,
1276
+ grainForPeriodKey,
1004
1277
  grains,
1005
1278
  hourKey,
1006
1279
  hourKeyParts,
1007
1280
  hoursBetween,
1008
1281
  maxWindowFor,
1009
1282
  monthKey,
1283
+ parsePeriodKey,
1010
1284
  processHour,
1011
1285
  requiredRecordFields,
1286
+ resolveBreakdownLive,
1287
+ resolveTotalsLive,
1012
1288
  runHourlyAthenaQuery,
1013
1289
  shiftAnchor,
1014
1290
  sumBreakdown,