@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/README.md +8 -4
- package/dist/dailyRollup/dailyRollup.mjs +1 -1
- package/dist/dailyRollup/dailyRollup.mjs.map +2 -2
- package/dist/hourly/hourly.mjs +2 -2
- package/dist/hourly/hourly.mjs.map +4 -4
- package/dist/index.d.ts +197 -100
- package/dist/index.js +306 -30
- package/dist/monthlyRollup/monthlyRollup.mjs +1 -1
- package/dist/monthlyRollup/monthlyRollup.mjs.map +2 -2
- package/package.json +1 -1
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
|
-
|
|
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/
|
|
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
|
|
1042
|
+
return pad42(date.getUTCFullYear());
|
|
793
1043
|
}
|
|
794
1044
|
function monthKey(date) {
|
|
795
|
-
return `${
|
|
1045
|
+
return `${pad42(date.getUTCFullYear())}-${pad22(date.getUTCMonth() + 1)}`;
|
|
796
1046
|
}
|
|
797
1047
|
function dayKey(date) {
|
|
798
|
-
return `${monthKey(date)}-${
|
|
1048
|
+
return `${monthKey(date)}-${pad22(date.getUTCDate())}`;
|
|
799
1049
|
}
|
|
800
1050
|
function hourKey(date) {
|
|
801
|
-
return `${dayKey(date)}-${
|
|
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
|
|
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
|
|
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 =
|
|
870
|
-
pk:
|
|
871
|
-
sk:
|
|
872
|
-
grain:
|
|
873
|
-
timeKey:
|
|
874
|
-
hits:
|
|
875
|
-
success:
|
|
876
|
-
errors:
|
|
877
|
-
errors4xx:
|
|
878
|
-
errors5xx:
|
|
879
|
-
bytesSent:
|
|
880
|
-
bytesReceived:
|
|
881
|
-
p50TimeTaken:
|
|
882
|
-
p95TimeTaken:
|
|
883
|
-
cacheHitRatio:
|
|
884
|
-
uniqueIpCount:
|
|
885
|
-
ttl:
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
1246
|
+
const result = v4.safeParse(totalsSchema, item);
|
|
979
1247
|
if (!result.success) {
|
|
980
|
-
console.error(
|
|
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,
|