@beesolve/analytics-processor 0.2.5 → 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
@@ -130,10 +130,10 @@ var requiredRecordFields = [
130
130
  ];
131
131
  var columnMaps = {
132
132
  v2: {
133
- status: "sc_status",
134
- bytesSent: "sc_bytes",
135
- bytesReceived: "cs_bytes",
136
- timeTaken: "time_taken",
133
+ status: "CAST(sc_status AS integer)",
134
+ bytesSent: "CAST(sc_bytes AS bigint)",
135
+ bytesReceived: "CAST(cs_bytes AS bigint)",
136
+ timeTaken: "CAST(time_taken AS double)",
137
137
  resultType: "x_edge_result_type",
138
138
  detailedResultType: "x_edge_detailed_result_type",
139
139
  uriStem: "cs_uri_stem",
@@ -203,32 +203,41 @@ function buildHourlyQuery({
203
203
  const columns = columnMaps[source];
204
204
  const predicate = predicateFor({ source, hourKey, columns });
205
205
  const device = deviceCaseExpression(columns.userAgent);
206
- const groupingSets = [
207
- "()",
208
- `(${columns.status})`,
209
- "(device)",
210
- `(${columns.uriStem})`,
211
- `(${columns.clientIp})`
212
- ].join(", ");
206
+ const inner = [
207
+ " SELECT",
208
+ ` ${columns.status} AS st,`,
209
+ ` ${columns.bytesSent} AS scb,`,
210
+ ` ${columns.bytesReceived} AS csb,`,
211
+ ` ${columns.timeTaken} AS tt,`,
212
+ ` ${columns.resultType} AS rt,`,
213
+ ` ${columns.uriStem} AS uri,`,
214
+ ` ${columns.clientIp} AS ip,`,
215
+ ` ${device} AS device`,
216
+ ` FROM "${table}"`,
217
+ ` WHERE ${predicate}`
218
+ ].join(`
219
+ `);
220
+ const groupingSets = ["()", "(st)", "(device)", "(uri)", "(ip)"].join(", ");
213
221
  return [
214
222
  "SELECT",
215
- ` ${columns.status} AS status_value,`,
216
- ` ${device} AS device,`,
217
- ` ${columns.uriStem} AS path_value,`,
218
- ` ${columns.clientIp} AS ip_value,`,
223
+ " st AS status_value,",
224
+ " device,",
225
+ " uri AS path_value,",
226
+ " ip AS ip_value,",
219
227
  " count(*) AS hits,",
220
- ` sum(CASE WHEN ${columns.status} BETWEEN 200 AND 399 THEN 1 ELSE 0 END) AS success,`,
221
- ` sum(CASE WHEN ${columns.status} BETWEEN 400 AND 599 THEN 1 ELSE 0 END) AS errors,`,
222
- ` sum(CASE WHEN ${columns.status} BETWEEN 400 AND 499 THEN 1 ELSE 0 END) AS errors4xx,`,
223
- ` sum(CASE WHEN ${columns.status} BETWEEN 500 AND 599 THEN 1 ELSE 0 END) AS errors5xx,`,
224
- ` sum(${columns.bytesSent}) AS bytes_sent,`,
225
- ` sum(${columns.bytesReceived}) AS bytes_received,`,
226
- ` approx_percentile(${columns.timeTaken}, 0.5) AS p50_time_taken,`,
227
- ` approx_percentile(${columns.timeTaken}, 0.95) AS p95_time_taken,`,
228
- ` sum(CASE WHEN ${columns.resultType} = 'Hit' THEN 1 ELSE 0 END) AS cache_hits,`,
229
- ` count(DISTINCT ${columns.clientIp}) AS unique_ip_count`,
230
- `FROM "${table}"`,
231
- `WHERE ${predicate}`,
228
+ " sum(CASE WHEN st BETWEEN 200 AND 399 THEN 1 ELSE 0 END) AS success,",
229
+ " sum(CASE WHEN st BETWEEN 400 AND 599 THEN 1 ELSE 0 END) AS errors,",
230
+ " sum(CASE WHEN st BETWEEN 400 AND 499 THEN 1 ELSE 0 END) AS errors4xx,",
231
+ " sum(CASE WHEN st BETWEEN 500 AND 599 THEN 1 ELSE 0 END) AS errors5xx,",
232
+ " sum(scb) AS bytes_sent,",
233
+ " sum(csb) AS bytes_received,",
234
+ " approx_percentile(tt, 0.5) AS p50_time_taken,",
235
+ " approx_percentile(tt, 0.95) AS p95_time_taken,",
236
+ " sum(CASE WHEN rt = 'Hit' THEN 1 ELSE 0 END) AS cache_hits,",
237
+ " count(DISTINCT ip) AS unique_ip_count",
238
+ "FROM (",
239
+ inner,
240
+ ")",
232
241
  `GROUP BY GROUPING SETS (${groupingSets})`
233
242
  ].join(`
234
243
  `);
@@ -473,6 +482,57 @@ class Breakdowns {
473
482
  function partitionKey2(props) {
474
483
  return `breakdown#${props.dimension}#${props.grain}#${props.periodKey}`;
475
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
+ }
476
536
  // packages/analytics-processor/src/device.ts
477
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;
478
538
  var mobilePattern = /mobi|android|iphone|ipad|ipod|iemobile|blackberry|opera mini|windows phone|webos/i;
@@ -553,11 +613,15 @@ async function processHour({
553
613
  byPath: query.byPath,
554
614
  byCountry
555
615
  });
556
- return sendTransacted({
616
+ const result = await sendTransacted({
557
617
  dynamo,
558
618
  leading: [markerPut, totalsUpdate],
559
619
  rest: breakdownUpdates
560
620
  });
621
+ if (result.status === "applied" && query.totals.hits > 0 && models.config != null) {
622
+ await models.config.ensureStart({ hourKey });
623
+ }
624
+ return result;
561
625
  }
562
626
  async function applyDailyRollup({
563
627
  dayKey,
@@ -772,24 +836,196 @@ function computeHourlyTtl(hourlyTtlDays) {
772
836
  function toBreakdownRow(item) {
773
837
  return { value: item.value, hits: item.hits, bytes: item.bytes };
774
838
  }
775
- // 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
+ }
776
987
  function pad2(value) {
777
988
  return value.toString().padStart(2, "0");
778
989
  }
779
990
  function pad4(value) {
780
991
  return value.toString().padStart(4, "0");
781
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
+ }
782
1000
  function yearKey(date) {
783
- return pad4(date.getUTCFullYear());
1001
+ return pad42(date.getUTCFullYear());
784
1002
  }
785
1003
  function monthKey(date) {
786
- return `${pad4(date.getUTCFullYear())}-${pad2(date.getUTCMonth() + 1)}`;
1004
+ return `${pad42(date.getUTCFullYear())}-${pad22(date.getUTCMonth() + 1)}`;
787
1005
  }
788
1006
  function dayKey(date) {
789
- return `${monthKey(date)}-${pad2(date.getUTCDate())}`;
1007
+ return `${monthKey(date)}-${pad22(date.getUTCDate())}`;
790
1008
  }
791
1009
  function hourKey(date) {
792
- 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";
793
1029
  }
794
1030
  function maxWindowFor(grain) {
795
1031
  if (grain === "hour")
@@ -839,9 +1075,9 @@ function hoursBetween({ from, to }) {
839
1075
  return keys;
840
1076
  }
841
1077
  // packages/analytics-processor/src/totals.ts
842
- 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";
843
1079
  import { queryAll as queryAll2 } from "@beesolve/dynamo-helpers";
844
- import * as v3 from "valibot";
1080
+ import * as v4 from "valibot";
845
1081
  var additiveFields = [
846
1082
  "hits",
847
1083
  "success",
@@ -857,23 +1093,23 @@ var hourlyDetailFields = [
857
1093
  "cacheHitRatio",
858
1094
  "uniqueIpCount"
859
1095
  ];
860
- var totalsSchema = v3.object({
861
- pk: v3.string(),
862
- sk: v3.string(),
863
- grain: v3.picklist(grains),
864
- timeKey: v3.string(),
865
- hits: v3.number(),
866
- success: v3.number(),
867
- errors: v3.number(),
868
- errors4xx: v3.number(),
869
- errors5xx: v3.number(),
870
- bytesSent: v3.number(),
871
- bytesReceived: v3.number(),
872
- p50TimeTaken: v3.optional(v3.number()),
873
- p95TimeTaken: v3.optional(v3.number()),
874
- cacheHitRatio: v3.optional(v3.number()),
875
- uniqueIpCount: v3.optional(v3.number()),
876
- 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())
877
1113
  });
878
1114
 
879
1115
  class Totals {
@@ -883,11 +1119,11 @@ class Totals {
883
1119
  }
884
1120
  addHourly = async (props) => {
885
1121
  const update = this.buildHourlyUpdate(props);
886
- await this.props.dynamo.send(new UpdateCommand2(update.Update));
1122
+ await this.props.dynamo.send(new UpdateCommand3(update.Update));
887
1123
  };
888
1124
  addAggregate = async (props) => {
889
1125
  const update = this.buildAggregateUpdate(props);
890
- await this.props.dynamo.send(new UpdateCommand2(update.Update));
1126
+ await this.props.dynamo.send(new UpdateCommand3(update.Update));
891
1127
  };
892
1128
  buildHourlyUpdate = (props) => ({
893
1129
  Update: this.buildUpdateMembers({
@@ -921,7 +1157,7 @@ class Totals {
921
1157
  return items.map((item) => this.parseOne(item));
922
1158
  };
923
1159
  getOne = async (props) => {
924
- const { Item: item } = await this.props.dynamo.send(new GetCommand2({
1160
+ const { Item: item } = await this.props.dynamo.send(new GetCommand3({
925
1161
  TableName: this.props.tableName,
926
1162
  Key: { pk: partitionKey3(props.grain), sk: props.timeKey }
927
1163
  }));
@@ -966,9 +1202,9 @@ class Totals {
966
1202
  };
967
1203
  };
968
1204
  parseOne = (item) => {
969
- const result = v3.safeParse(totalsSchema, item);
1205
+ const result = v4.safeParse(totalsSchema, item);
970
1206
  if (!result.success) {
971
- console.error(v3.flatten(result.issues));
1207
+ console.error(v4.flatten(result.issues));
972
1208
  throw new Error("Malformed stored totals record");
973
1209
  }
974
1210
  return result.output;
@@ -980,6 +1216,7 @@ function partitionKey3(grain) {
980
1216
  export {
981
1217
  AppliedMarkers,
982
1218
  Breakdowns,
1219
+ Config,
983
1220
  Totals,
984
1221
  appliedMarkerSchema,
985
1222
  appliedSources,
@@ -990,16 +1227,21 @@ export {
990
1227
  chunkTransactItems,
991
1228
  classifyDevice,
992
1229
  columnMaps,
1230
+ configSchema,
993
1231
  dayKey,
994
1232
  dimensions,
1233
+ grainForPeriodKey,
995
1234
  grains,
996
1235
  hourKey,
997
1236
  hourKeyParts,
998
1237
  hoursBetween,
999
1238
  maxWindowFor,
1000
1239
  monthKey,
1240
+ parsePeriodKey,
1001
1241
  processHour,
1002
1242
  requiredRecordFields,
1243
+ resolveBreakdownLive,
1244
+ resolveTotalsLive,
1003
1245
  runHourlyAthenaQuery,
1004
1246
  shiftAnchor,
1005
1247
  sumBreakdown,