@beesolve/analytics-processor 0.2.6 → 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/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,196 @@ 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 emptyTotals = {
841
+ hits: 0,
842
+ success: 0,
843
+ errors: 0,
844
+ errors4xx: 0,
845
+ errors5xx: 0,
846
+ bytesSent: 0,
847
+ bytesReceived: 0
848
+ };
849
+ async function resolveTotalsLive({
850
+ models,
851
+ grain,
852
+ periodKey,
853
+ now
854
+ }) {
855
+ if (grain === "hour") {
856
+ const stored = await models.totals.getOne({ grain: "hour", timeKey: periodKey });
857
+ return stored == null ? emptyTotals : pickTotals(stored);
858
+ }
859
+ if (await isSealed({ models, grain, periodKey, now })) {
860
+ const stored = await models.totals.getOne({ grain, timeKey: periodKey });
861
+ if (stored != null)
862
+ return pickTotals(stored);
863
+ }
864
+ if (grain === "day") {
865
+ const lastHour = isCurrentPeriod({ grain: "day", periodKey, now }) ? now.getUTCHours() : 23;
866
+ const hours = await models.totals.queryWindow({
867
+ grain: "hour",
868
+ startKey: `${periodKey}-00`,
869
+ endKey: `${periodKey}-${pad2(lastHour)}`
870
+ });
871
+ return sumTotals(hours);
872
+ }
873
+ const dayKeys = dayKeysInProgress({ periodKey, now });
874
+ const sealedDays = await models.totals.queryWindow({
875
+ grain: "day",
876
+ startKey: `${periodKey}-01`,
877
+ endKey: `${periodKey}-31`
878
+ });
879
+ const sealedByKey = indexByTimeKey(sealedDays);
880
+ const resolvedDays = await Promise.all(dayKeys.map(async (dayKey) => {
881
+ const stored = sealedByKey[dayKey];
882
+ if (stored != null && !isCurrentPeriod({ grain: "day", periodKey: dayKey, now })) {
883
+ return pickTotals(stored);
884
+ }
885
+ return resolveTotalsLive({ models, grain: "day", periodKey: dayKey, now });
886
+ }));
887
+ return sumTotals(resolvedDays);
888
+ }
889
+ async function resolveBreakdownLive({
890
+ models,
891
+ dimension,
892
+ grain,
893
+ periodKey,
894
+ now
895
+ }) {
896
+ if (grain === "hour") {
897
+ return toRows(await models.breakdowns.getForPeriod({ dimension, grain: "hour", periodKey }));
898
+ }
899
+ if (await isSealed({ models, grain, periodKey, now })) {
900
+ const stored = await models.breakdowns.getForPeriod({ dimension, grain, periodKey });
901
+ if (stored.length > 0)
902
+ return toRows(stored);
903
+ }
904
+ const finer = finerGrain(grain);
905
+ const childKeys = grain === "day" ? hourKeysInProgress({ periodKey, now }) : dayKeysInProgress({ periodKey, now });
906
+ const children = await Promise.all(childKeys.map((childKey) => resolveBreakdownLive({ models, dimension, grain: finer, periodKey: childKey, now })));
907
+ return sumBreakdown(children.flat());
908
+ }
909
+ async function isSealed({
910
+ models,
911
+ grain,
912
+ periodKey,
913
+ now
914
+ }) {
915
+ if (isCurrentPeriod({ grain, periodKey, now }))
916
+ return false;
917
+ return models.markers.isApplied({ grain, periodKey });
918
+ }
919
+ function isCurrentPeriod({
920
+ grain,
921
+ periodKey,
922
+ now
923
+ }) {
924
+ return periodKey === currentKeyFor({ grain, now });
925
+ }
926
+ function currentKeyFor({ grain, now }) {
927
+ const year = pad4(now.getUTCFullYear());
928
+ const month = pad2(now.getUTCMonth() + 1);
929
+ const day = pad2(now.getUTCDate());
930
+ const hour = pad2(now.getUTCHours());
931
+ if (grain === "month")
932
+ return `${year}-${month}`;
933
+ if (grain === "day")
934
+ return `${year}-${month}-${day}`;
935
+ return `${year}-${month}-${day}-${hour}`;
936
+ }
937
+ function finerGrain(grain) {
938
+ return grain === "month" ? "day" : "hour";
939
+ }
940
+ function hourKeysInProgress({
941
+ periodKey,
942
+ now
943
+ }) {
944
+ const lastHour = isCurrentPeriod({ grain: "day", periodKey, now }) ? now.getUTCHours() : 23;
945
+ const keys = [];
946
+ for (let hour = 0;hour <= lastHour; hour += 1) {
947
+ keys.push(`${periodKey}-${pad2(hour)}`);
948
+ }
949
+ return keys;
950
+ }
951
+ function dayKeysInProgress({
952
+ periodKey,
953
+ now
954
+ }) {
955
+ const lastDay = isCurrentPeriod({ grain: "month", periodKey, now }) ? now.getUTCDate() : daysInMonth(periodKey);
956
+ const keys = [];
957
+ for (let day = 1;day <= lastDay; day += 1) {
958
+ keys.push(`${periodKey}-${pad2(day)}`);
959
+ }
960
+ return keys;
961
+ }
962
+ function daysInMonth(monthKey) {
963
+ const [year, month] = monthKey.split("-").map(Number);
964
+ return new Date(Date.UTC(year ?? 1970, month ?? 1, 0)).getUTCDate();
965
+ }
966
+ function indexByTimeKey(items) {
967
+ const byKey = {};
968
+ for (const item of items) {
969
+ byKey[item.timeKey] = item;
970
+ }
971
+ return byKey;
972
+ }
973
+ function toRows(items) {
974
+ return items.map((item) => ({ value: item.value, hits: item.hits, bytes: item.bytes }));
975
+ }
976
+ function pickTotals(item) {
977
+ return {
978
+ hits: item.hits,
979
+ success: item.success,
980
+ errors: item.errors,
981
+ errors4xx: item.errors4xx,
982
+ errors5xx: item.errors5xx,
983
+ bytesSent: item.bytesSent,
984
+ bytesReceived: item.bytesReceived
985
+ };
986
+ }
785
987
  function pad2(value) {
786
988
  return value.toString().padStart(2, "0");
787
989
  }
788
990
  function pad4(value) {
789
991
  return value.toString().padStart(4, "0");
790
992
  }
993
+ // packages/analytics-processor/src/time.ts
994
+ function pad22(value) {
995
+ return value.toString().padStart(2, "0");
996
+ }
997
+ function pad42(value) {
998
+ return value.toString().padStart(4, "0");
999
+ }
791
1000
  function yearKey(date) {
792
- return pad4(date.getUTCFullYear());
1001
+ return pad42(date.getUTCFullYear());
793
1002
  }
794
1003
  function monthKey(date) {
795
- return `${pad4(date.getUTCFullYear())}-${pad2(date.getUTCMonth() + 1)}`;
1004
+ return `${pad42(date.getUTCFullYear())}-${pad22(date.getUTCMonth() + 1)}`;
796
1005
  }
797
1006
  function dayKey(date) {
798
- return `${monthKey(date)}-${pad2(date.getUTCDate())}`;
1007
+ return `${monthKey(date)}-${pad22(date.getUTCDate())}`;
799
1008
  }
800
1009
  function hourKey(date) {
801
- return `${dayKey(date)}-${pad2(date.getUTCHours())}`;
1010
+ return `${dayKey(date)}-${pad22(date.getUTCHours())}`;
1011
+ }
1012
+ function parsePeriodKey(periodKey) {
1013
+ const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(periodKey);
1014
+ if (match == null) {
1015
+ throw new Error(`Invalid period key: ${periodKey}`);
1016
+ }
1017
+ const [, year, month, day, hour] = match;
1018
+ return new Date(Date.UTC(Number(year), month == null ? 0 : Number(month) - 1, day == null ? 1 : Number(day), hour == null ? 0 : Number(hour)));
1019
+ }
1020
+ function grainForPeriodKey(periodKey) {
1021
+ const segments = periodKey.split("-").length;
1022
+ if (segments === 1)
1023
+ return "year";
1024
+ if (segments === 2)
1025
+ return "month";
1026
+ if (segments === 3)
1027
+ return "day";
1028
+ return "hour";
802
1029
  }
803
1030
  function maxWindowFor(grain) {
804
1031
  if (grain === "hour")
@@ -848,9 +1075,9 @@ function hoursBetween({ from, to }) {
848
1075
  return keys;
849
1076
  }
850
1077
  // packages/analytics-processor/src/totals.ts
851
- import { GetCommand as GetCommand2, UpdateCommand as UpdateCommand2 } from "@aws-sdk/lib-dynamodb";
1078
+ import { GetCommand as GetCommand3, UpdateCommand as UpdateCommand3 } from "@aws-sdk/lib-dynamodb";
852
1079
  import { queryAll as queryAll2 } from "@beesolve/dynamo-helpers";
853
- import * as v3 from "valibot";
1080
+ import * as v4 from "valibot";
854
1081
  var additiveFields = [
855
1082
  "hits",
856
1083
  "success",
@@ -866,23 +1093,23 @@ var hourlyDetailFields = [
866
1093
  "cacheHitRatio",
867
1094
  "uniqueIpCount"
868
1095
  ];
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())
1096
+ var totalsSchema = v4.object({
1097
+ pk: v4.string(),
1098
+ sk: v4.string(),
1099
+ grain: v4.picklist(grains),
1100
+ timeKey: v4.string(),
1101
+ hits: v4.number(),
1102
+ success: v4.number(),
1103
+ errors: v4.number(),
1104
+ errors4xx: v4.number(),
1105
+ errors5xx: v4.number(),
1106
+ bytesSent: v4.number(),
1107
+ bytesReceived: v4.number(),
1108
+ p50TimeTaken: v4.optional(v4.number()),
1109
+ p95TimeTaken: v4.optional(v4.number()),
1110
+ cacheHitRatio: v4.optional(v4.number()),
1111
+ uniqueIpCount: v4.optional(v4.number()),
1112
+ ttl: v4.optional(v4.number())
886
1113
  });
887
1114
 
888
1115
  class Totals {
@@ -892,11 +1119,11 @@ class Totals {
892
1119
  }
893
1120
  addHourly = async (props) => {
894
1121
  const update = this.buildHourlyUpdate(props);
895
- await this.props.dynamo.send(new UpdateCommand2(update.Update));
1122
+ await this.props.dynamo.send(new UpdateCommand3(update.Update));
896
1123
  };
897
1124
  addAggregate = async (props) => {
898
1125
  const update = this.buildAggregateUpdate(props);
899
- await this.props.dynamo.send(new UpdateCommand2(update.Update));
1126
+ await this.props.dynamo.send(new UpdateCommand3(update.Update));
900
1127
  };
901
1128
  buildHourlyUpdate = (props) => ({
902
1129
  Update: this.buildUpdateMembers({
@@ -930,7 +1157,7 @@ class Totals {
930
1157
  return items.map((item) => this.parseOne(item));
931
1158
  };
932
1159
  getOne = async (props) => {
933
- const { Item: item } = await this.props.dynamo.send(new GetCommand2({
1160
+ const { Item: item } = await this.props.dynamo.send(new GetCommand3({
934
1161
  TableName: this.props.tableName,
935
1162
  Key: { pk: partitionKey3(props.grain), sk: props.timeKey }
936
1163
  }));
@@ -975,9 +1202,9 @@ class Totals {
975
1202
  };
976
1203
  };
977
1204
  parseOne = (item) => {
978
- const result = v3.safeParse(totalsSchema, item);
1205
+ const result = v4.safeParse(totalsSchema, item);
979
1206
  if (!result.success) {
980
- console.error(v3.flatten(result.issues));
1207
+ console.error(v4.flatten(result.issues));
981
1208
  throw new Error("Malformed stored totals record");
982
1209
  }
983
1210
  return result.output;
@@ -989,6 +1216,7 @@ function partitionKey3(grain) {
989
1216
  export {
990
1217
  AppliedMarkers,
991
1218
  Breakdowns,
1219
+ Config,
992
1220
  Totals,
993
1221
  appliedMarkerSchema,
994
1222
  appliedSources,
@@ -999,16 +1227,21 @@ export {
999
1227
  chunkTransactItems,
1000
1228
  classifyDevice,
1001
1229
  columnMaps,
1230
+ configSchema,
1002
1231
  dayKey,
1003
1232
  dimensions,
1233
+ grainForPeriodKey,
1004
1234
  grains,
1005
1235
  hourKey,
1006
1236
  hourKeyParts,
1007
1237
  hoursBetween,
1008
1238
  maxWindowFor,
1009
1239
  monthKey,
1240
+ parsePeriodKey,
1010
1241
  processHour,
1011
1242
  requiredRecordFields,
1243
+ resolveBreakdownLive,
1244
+ resolveTotalsLive,
1012
1245
  runHourlyAthenaQuery,
1013
1246
  shiftAnchor,
1014
1247
  sumBreakdown,