@reventlessdev/reventless-aws 3.0.0-alpha.336 → 3.0.0-alpha.338
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/CHANGELOG.md +52 -0
- package/package.json +9 -9
- package/src/Platform.res +59 -5
- package/src/Platform.res.mjs +27 -4
- package/src/adapter/Monitoring/Monitoring_CloudWatch.res +197 -0
- package/src/adapter/Monitoring/Monitoring_CloudWatch.res.mjs +144 -0
- package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res +10 -2
- package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res.mjs +3 -2
- package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +10 -11
- package/src/util/Util_AlarmSpec.res +155 -0
- package/src/util/Util_AlarmSpec.res.mjs +108 -0
- package/src/util/Util_DeadLetterQueue.res +70 -13
- package/src/util/Util_DeadLetterQueue.res.mjs +27 -6
- package/src/util/Util_ShellConfig.res +14 -0
- package/src/util/Util_ShellConfig.res.mjs +5 -1
- package/src/util/Util_StaticBundle.res +21 -0
- package/src/util/Util_StaticBundle.res.mjs +8 -0
- package/tests/Util_ShellConfigTest.res +42 -0
- package/tests/Util_ShellConfigTest.res.mjs +53 -15
- package/tests/util/Util_AlarmSpecTest.res +109 -0
- package/tests/util/Util_AlarmSpecTest.res.mjs +78 -0
|
@@ -62,11 +62,11 @@ external scanByTableName: (
|
|
|
62
62
|
let exnMessage = (exn: exn): string =>
|
|
63
63
|
exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error")
|
|
64
64
|
|
|
65
|
-
// ──
|
|
66
|
-
// pluginDefinition
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
65
|
+
// ── Absent-field normalization ──────────────────────────────────────────────
|
|
66
|
+
// pluginDefinition's optionals omit the key, so a plain JSON.parse (not a sury
|
|
67
|
+
// parse) yields `undefined` and ReScript reads it as None unaided. Kept because
|
|
68
|
+
// the same records also arrive from stores and peers written when an absent
|
|
69
|
+
// field was `null`, which ReScript's option would misread as Some.
|
|
70
70
|
|
|
71
71
|
external optionAsNullable: option<'a> => Nullable.t<'a> = "%identity"
|
|
72
72
|
let jsOption = (v: option<'a>): option<'a> => v->optionAsNullable->Nullable.toOption
|
|
@@ -237,10 +237,9 @@ let parseHandlerConfig = (rawJson: string): handlerConfig => {
|
|
|
237
237
|
|
|
238
238
|
// ── Plugin-RM row projection ────────────────────────────────────────────────
|
|
239
239
|
// The subset of the Plugin read-model state the subscription manager needs.
|
|
240
|
-
// Deliberately sidesteps sury:
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
// parser rejects them. Field types reuse the spec's definition records — the
|
|
240
|
+
// Deliberately sidesteps sury: DDB drops undefined attributes on write, so the
|
|
241
|
+
// unmarshalled row can arrive with keys sury's strict parser still expects — the
|
|
242
|
+
// offload fields among them, whose codec keeps its `null` arm. Field types reuse the spec's definition records — the
|
|
244
243
|
// projection is a strict subset of Reventless.Plugin.pluginDefinition.
|
|
245
244
|
|
|
246
245
|
type pluginProjection = {
|
|
@@ -325,8 +324,8 @@ let projectPluginRow = (row: JSON.t): option<pluginProjection> =>
|
|
|
325
324
|
|
|
326
325
|
// A pluginDefinition viewed as a projection (the manage logic reads only the
|
|
327
326
|
// shared subset; `status` is never read for the acting plugin). Array/option
|
|
328
|
-
// fields stay
|
|
329
|
-
//
|
|
327
|
+
// fields stay normalized: a definition arriving over the wire, rather than from
|
|
328
|
+
// loadPluginDefinition's decode, may still carry null for an absent field.
|
|
330
329
|
let projectionOfDefinition = (d: Reventless.Plugin.pluginDefinition): pluginProjection => {
|
|
331
330
|
id: d.id,
|
|
332
331
|
status: "",
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// What to alarm on, per kind of execution unit — the pure half of the CloudWatch
|
|
2
|
+
// monitoring backend. `Monitoring_CloudWatch` turns these into resources; every
|
|
3
|
+
// decision worth arguing with is here, where a test can reach it without
|
|
4
|
+
// importing @pulumi/pulumi. Same split as Util_LogRetention / Util_LambdaLogging.
|
|
5
|
+
|
|
6
|
+
module M = ReventlessCore.Monitoring
|
|
7
|
+
|
|
8
|
+
/** One alarm. `suffix` distinguishes several alarms on one unit; the empty
|
|
9
|
+
string is the unit's primary alarm. */
|
|
10
|
+
type t = {
|
|
11
|
+
metricName: string,
|
|
12
|
+
namespace: string,
|
|
13
|
+
/** CloudWatch operator name, e.g. `"GreaterThanOrEqualToThreshold"`. */
|
|
14
|
+
comparisonOperator: string,
|
|
15
|
+
threshold: float,
|
|
16
|
+
/** Seconds. */
|
|
17
|
+
period: int,
|
|
18
|
+
evaluationPeriods: int,
|
|
19
|
+
statistic: string,
|
|
20
|
+
/** `"missing"` | `"notBreaching"` | `"breaching"` | `"ignore"`. */
|
|
21
|
+
treatMissingData: string,
|
|
22
|
+
suffix: string,
|
|
23
|
+
/** One clause, for the description a person reads in the notification. */
|
|
24
|
+
meaning: string,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** How long a scheduled unit may produce no invocation before that silence is
|
|
28
|
+
itself the fault. The default is an hour: the plugin heartbeat's default
|
|
29
|
+
interval is 5 minutes, so an hour is twelve missed beats — far enough out that
|
|
30
|
+
a slow deploy or a throttle cannot trip it, close enough that a stopped
|
|
31
|
+
scheduler is caught within the hour rather than by its consequences days
|
|
32
|
+
later. Deployments that set a long `heartbeatInterval` should raise it. */
|
|
33
|
+
let defaultSilenceWindowSeconds = 60 * 60
|
|
34
|
+
|
|
35
|
+
let errorsAlarm = {
|
|
36
|
+
metricName: "Errors",
|
|
37
|
+
namespace: "AWS/Lambda",
|
|
38
|
+
comparisonOperator: "GreaterThanOrEqualToThreshold",
|
|
39
|
+
threshold: 1.0,
|
|
40
|
+
period: 300,
|
|
41
|
+
evaluationPeriods: 1,
|
|
42
|
+
statistic: "Sum",
|
|
43
|
+
// A unit that is not running produces no datapoints, and "no traffic" is not a
|
|
44
|
+
// failure — an idle stack must not sit permanently in alarm, or every alarm it
|
|
45
|
+
// raises is discounted.
|
|
46
|
+
treatMissingData: "notBreaching",
|
|
47
|
+
suffix: "",
|
|
48
|
+
meaning: "is failing on its messages (Errors >= 1 in 5min)",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
The alarms for one kind of execution unit. More than one is allowed, because for
|
|
53
|
+
some kinds a single metric cannot express the fault.
|
|
54
|
+
|
|
55
|
+
- **Dead-letter sink** — the metric is `Invocations`, not `Errors`. This handler
|
|
56
|
+
fails on every delivery by design, so `Errors` says only that it is doing its
|
|
57
|
+
job; that it ran at all is the incident.
|
|
58
|
+
- **Scheduler** — two alarms, and the second is the one that matters. A heartbeat
|
|
59
|
+
that *stops* emits no errors and no invocations, so the absence is the fault and
|
|
60
|
+
`Errors` cannot see it. The silence alarm inverts the test (`Invocations < 1`)
|
|
61
|
+
and treats missing data as breaching, which is the only combination that fires
|
|
62
|
+
on a metric that has stopped being published. This is the exact shape that went
|
|
63
|
+
unnoticed on a deployed estate: plugins stopped heartbeating and nothing said so.
|
|
64
|
+
- **Everything else** — `Errors >= 1`.
|
|
65
|
+
*/
|
|
66
|
+
let forKind = (~kind: M.unitKind, ~silenceWindowSeconds=defaultSilenceWindowSeconds): array<t> =>
|
|
67
|
+
switch kind {
|
|
68
|
+
| DeadLetterSink => [
|
|
69
|
+
{
|
|
70
|
+
...errorsAlarm,
|
|
71
|
+
metricName: "Invocations",
|
|
72
|
+
meaning: "received a dead letter (an invocation here IS the incident)",
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
| Scheduler => [
|
|
76
|
+
errorsAlarm,
|
|
77
|
+
{
|
|
78
|
+
...errorsAlarm,
|
|
79
|
+
metricName: "Invocations",
|
|
80
|
+
comparisonOperator: "LessThanThreshold",
|
|
81
|
+
period: silenceWindowSeconds,
|
|
82
|
+
// The point of the alarm: a metric that stopped being published is the
|
|
83
|
+
// failure, so its absence must breach rather than be excused.
|
|
84
|
+
treatMissingData: "breaching",
|
|
85
|
+
suffix: "Silent",
|
|
86
|
+
meaning: "has not run for " ++ (silenceWindowSeconds / 60)->Int.toString ++ " minutes",
|
|
87
|
+
},
|
|
88
|
+
]
|
|
89
|
+
| _ => [errorsAlarm]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Lower-case, hyphen-free rendering of a kind, for a resource name and for the
|
|
93
|
+
machine-readable `kind=` in a description. `Other` carries its own word. */
|
|
94
|
+
let kindSlug = (kind: M.unitKind): string =>
|
|
95
|
+
switch kind {
|
|
96
|
+
| CommandHandler => "commandhandler"
|
|
97
|
+
| Projection => "projection"
|
|
98
|
+
| Reactor => "reactor"
|
|
99
|
+
| EventCollector => "eventcollector"
|
|
100
|
+
| Task => "task"
|
|
101
|
+
| Scheduler => "scheduler"
|
|
102
|
+
| DeadLetterSink => "deadlettersink"
|
|
103
|
+
| Other(word) => "other" ++ word->String.toLowerCase
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
The alarm's logical (Pulumi) resource name.
|
|
108
|
+
|
|
109
|
+
Carries the plugin when there is one: `~name` is the component only, and two
|
|
110
|
+
plugins in a platform can own like-named components — a collision here is a
|
|
111
|
+
Pulumi duplicate-resource failure at deploy time, not a subtle one, but the fix
|
|
112
|
+
belongs in the name rather than in the reader's memory.
|
|
113
|
+
*/
|
|
114
|
+
let resourceName = (~kind: M.unitKind, ~name: string, ~plugin: option<string>, ~suffix: string) => {
|
|
115
|
+
let owner = switch plugin {
|
|
116
|
+
| Some(p) => p ++ "-"
|
|
117
|
+
| None => ""
|
|
118
|
+
}
|
|
119
|
+
`alarm-${kind->kindSlug}-${owner}${name}${suffix}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
What the notification says.
|
|
124
|
+
|
|
125
|
+
Two audiences in one string, because a CloudWatch state-change message carries the
|
|
126
|
+
description and neither the alarm's tags nor its name: a sentence for the person
|
|
127
|
+
who reads the mail, and a bracketed token for anything parsing it. Without the
|
|
128
|
+
platform the mail cannot say which estate fired — one platform runs many plugins
|
|
129
|
+
whose components are named by role, so `CatalogPluginHeartbeat` alone is ambiguous
|
|
130
|
+
across stacks.
|
|
131
|
+
*/
|
|
132
|
+
let description = (
|
|
133
|
+
~kind: M.unitKind,
|
|
134
|
+
~name: string,
|
|
135
|
+
~plugin: option<string>,
|
|
136
|
+
~platform: option<string>,
|
|
137
|
+
~spec: t,
|
|
138
|
+
~logs: option<string>,
|
|
139
|
+
) => {
|
|
140
|
+
let owner = switch (plugin, platform) {
|
|
141
|
+
| (Some(p), Some(pl)) => ` (plugin ${p}, platform ${pl})`
|
|
142
|
+
| (Some(p), None) => ` (plugin ${p})`
|
|
143
|
+
| (None, Some(pl)) => ` (platform ${pl})`
|
|
144
|
+
// Platform substrate, owned by no plugin — a dead-letter queue is shared by
|
|
145
|
+
// every plugin in the estate, so naming one would be a lie.
|
|
146
|
+
| (None, None) => ""
|
|
147
|
+
}
|
|
148
|
+
let logsClause = switch logs {
|
|
149
|
+
| Some(group) => ` Logs: ${group}.`
|
|
150
|
+
| None => ""
|
|
151
|
+
}
|
|
152
|
+
`Reventless: ${kind->kindSlug} '${name}'${owner} ${spec.meaning}.${logsClause}` ++
|
|
153
|
+
` [reventless plugin=${plugin->Option.getOr("")} platform=${platform->Option.getOr("")}` ++
|
|
154
|
+
` component=${name} kind=${kind->kindSlug}]`
|
|
155
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
|
|
5
|
+
let errorsAlarm = {
|
|
6
|
+
metricName: "Errors",
|
|
7
|
+
namespace: "AWS/Lambda",
|
|
8
|
+
comparisonOperator: "GreaterThanOrEqualToThreshold",
|
|
9
|
+
threshold: 1.0,
|
|
10
|
+
period: 300,
|
|
11
|
+
evaluationPeriods: 1,
|
|
12
|
+
statistic: "Sum",
|
|
13
|
+
treatMissingData: "notBreaching",
|
|
14
|
+
suffix: "",
|
|
15
|
+
meaning: "is failing on its messages (Errors >= 1 in 5min)"
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function forKind(kind, silenceWindowSecondsOpt) {
|
|
19
|
+
let silenceWindowSeconds = silenceWindowSecondsOpt !== undefined ? silenceWindowSecondsOpt : 3600;
|
|
20
|
+
if (typeof kind === "object") {
|
|
21
|
+
return [errorsAlarm];
|
|
22
|
+
}
|
|
23
|
+
switch (kind) {
|
|
24
|
+
case "Scheduler" :
|
|
25
|
+
return [
|
|
26
|
+
errorsAlarm,
|
|
27
|
+
{
|
|
28
|
+
metricName: "Invocations",
|
|
29
|
+
namespace: "AWS/Lambda",
|
|
30
|
+
comparisonOperator: "LessThanThreshold",
|
|
31
|
+
threshold: 1.0,
|
|
32
|
+
period: silenceWindowSeconds,
|
|
33
|
+
evaluationPeriods: 1,
|
|
34
|
+
statistic: "Sum",
|
|
35
|
+
treatMissingData: "breaching",
|
|
36
|
+
suffix: "Silent",
|
|
37
|
+
meaning: "has not run for " + (silenceWindowSeconds / 60 | 0).toString() + " minutes"
|
|
38
|
+
}
|
|
39
|
+
];
|
|
40
|
+
case "DeadLetterSink" :
|
|
41
|
+
return [{
|
|
42
|
+
metricName: "Invocations",
|
|
43
|
+
namespace: "AWS/Lambda",
|
|
44
|
+
comparisonOperator: "GreaterThanOrEqualToThreshold",
|
|
45
|
+
threshold: 1.0,
|
|
46
|
+
period: 300,
|
|
47
|
+
evaluationPeriods: 1,
|
|
48
|
+
statistic: "Sum",
|
|
49
|
+
treatMissingData: "notBreaching",
|
|
50
|
+
suffix: "",
|
|
51
|
+
meaning: "received a dead letter (an invocation here IS the incident)"
|
|
52
|
+
}];
|
|
53
|
+
default:
|
|
54
|
+
return [errorsAlarm];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function kindSlug(kind) {
|
|
59
|
+
if (typeof kind === "object") {
|
|
60
|
+
return "other" + kind._0.toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
switch (kind) {
|
|
63
|
+
case "CommandHandler" :
|
|
64
|
+
return "commandhandler";
|
|
65
|
+
case "Projection" :
|
|
66
|
+
return "projection";
|
|
67
|
+
case "Reactor" :
|
|
68
|
+
return "reactor";
|
|
69
|
+
case "EventCollector" :
|
|
70
|
+
return "eventcollector";
|
|
71
|
+
case "Task" :
|
|
72
|
+
return "task";
|
|
73
|
+
case "Scheduler" :
|
|
74
|
+
return "scheduler";
|
|
75
|
+
case "DeadLetterSink" :
|
|
76
|
+
return "deadlettersink";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resourceName(kind, name, plugin, suffix) {
|
|
81
|
+
let owner = plugin !== undefined ? plugin + "-" : "";
|
|
82
|
+
return `alarm-` + kindSlug(kind) + `-` + owner + name + suffix;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function description(kind, name, plugin, platform, spec, logs) {
|
|
86
|
+
let owner = plugin !== undefined ? (
|
|
87
|
+
platform !== undefined ? ` (plugin ` + plugin + `, platform ` + platform + `)` : ` (plugin ` + plugin + `)`
|
|
88
|
+
) : (
|
|
89
|
+
platform !== undefined ? ` (platform ` + platform + `)` : ""
|
|
90
|
+
);
|
|
91
|
+
let logsClause = logs !== undefined ? ` Logs: ` + logs + `.` : "";
|
|
92
|
+
return `Reventless: ` + kindSlug(kind) + ` '` + name + `'` + owner + ` ` + spec.meaning + `.` + logsClause + (` [reventless plugin=` + Stdlib_Option.getOr(plugin, "") + ` platform=` + Stdlib_Option.getOr(platform, "")) + (` component=` + name + ` kind=` + kindSlug(kind) + `]`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let M;
|
|
96
|
+
|
|
97
|
+
let defaultSilenceWindowSeconds = 3600;
|
|
98
|
+
|
|
99
|
+
export {
|
|
100
|
+
M,
|
|
101
|
+
defaultSilenceWindowSeconds,
|
|
102
|
+
errorsAlarm,
|
|
103
|
+
forKind,
|
|
104
|
+
kindSlug,
|
|
105
|
+
resourceName,
|
|
106
|
+
description,
|
|
107
|
+
}
|
|
108
|
+
/* No side effect */
|
|
@@ -17,10 +17,25 @@ let queueTags = queueName =>
|
|
|
17
17
|
// 4-day default can expire it over a long weekend before anyone reads it.
|
|
18
18
|
let retentionSeconds = 14 * 24 * 60 * 60
|
|
19
19
|
|
|
20
|
+
// A dead letter has no latency requirement, and the handler below fails on
|
|
21
|
+
// purpose — so a message returns to the queue and is redelivered until retention
|
|
22
|
+
// expires. At 180 s that is ~480 redeliveries per message per day, which outlives
|
|
23
|
+
// the fault by however long nobody looks: seven heartbeats stranded by a since-fixed
|
|
24
|
+
// decode error were still cycling twelve hours after the cause was cured. Fifteen
|
|
25
|
+
// minutes cuts that 5× and costs nothing anybody is waiting on. It must stay above
|
|
26
|
+
// the handler's timeout (30 s), which it is by a wide margin.
|
|
27
|
+
//
|
|
28
|
+
// It bounds the rate, not the count — the loop still ends only at retention. What
|
|
29
|
+
// ends it early is an operator, and what fetches one is an alarm on this handler's
|
|
30
|
+
// `Invocations` (an invocation here IS the incident). That the alarm does not
|
|
31
|
+
// currently fire is a defect in when this module announces itself, not a reason
|
|
32
|
+
// for the queue to carry a redrive target of its own.
|
|
33
|
+
let visibilityTimeoutSeconds = 15 * 60
|
|
34
|
+
|
|
20
35
|
let queue = SQS.Queue.make(
|
|
21
36
|
~name,
|
|
22
37
|
~args={
|
|
23
|
-
SQS.Queue.visibilityTimeoutSeconds:
|
|
38
|
+
SQS.Queue.visibilityTimeoutSeconds: visibilityTimeoutSeconds->Pulumi.Input.make,
|
|
24
39
|
messageRetentionSeconds: retentionSeconds->Pulumi.Input.make,
|
|
25
40
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
26
41
|
tags: queueTags(name),
|
|
@@ -32,7 +47,7 @@ let fifoQueue = SQS.Queue.make(
|
|
|
32
47
|
~args={
|
|
33
48
|
SQS.Queue.fifoQueue: true->Pulumi.Input.make,
|
|
34
49
|
contentBasedDeduplication: true->Pulumi.Input.make,
|
|
35
|
-
visibilityTimeoutSeconds:
|
|
50
|
+
visibilityTimeoutSeconds: visibilityTimeoutSeconds->Pulumi.Input.make,
|
|
36
51
|
messageRetentionSeconds: retentionSeconds->Pulumi.Input.make,
|
|
37
52
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
38
53
|
tags: queueTags(nameFifo),
|
|
@@ -61,16 +76,41 @@ let lambdaRole = IAM.Role.makeWithDefaultPolicy(
|
|
|
61
76
|
// plugin whose commands failed every 5 minutes for two days produced 217 dead
|
|
62
77
|
// letters and no signal at all; it was found by a person noticing a stale UI.
|
|
63
78
|
//
|
|
64
|
-
// Failing instead keeps the message on the queue
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
79
|
+
// Failing instead keeps the message on the queue and keeps `Errors` non-zero
|
|
80
|
+
// while the condition lasts. Both are conventional alarm subjects, and a
|
|
81
|
+
// monitoring backend attached through the `DeadLetterSink` seam below now has
|
|
82
|
+
// something to attach to.
|
|
83
|
+
//
|
|
84
|
+
// What that reasoning did not account for is that the transport retries by
|
|
85
|
+
// design too: a failed batch returns to the queue and is redelivered until
|
|
86
|
+
// retention expires. A handler that fails forever, on a transport that retries
|
|
87
|
+
// forever, is a loop — and it outlives its cause, because nothing about a fixed
|
|
88
|
+
// bug removes the message that was stranded by it. Seven of them were still
|
|
89
|
+
// cycling twelve hours after the fix that made them impossible.
|
|
90
|
+
//
|
|
91
|
+
// What the loop costs is now a function of the line, not the count: the full
|
|
92
|
+
// record is written once — on the first delivery, the one carrying the diagnostic
|
|
93
|
+
// — and every redelivery after it costs a single identity line. A stranded
|
|
94
|
+
// message is ~150 KB over a fortnight rather than ~17 MB, which is small enough
|
|
95
|
+
// that ending the loop early is an operator's job rather than the topology's.
|
|
70
96
|
let entryPointCode = `export const handler = async (event) => {
|
|
71
|
-
|
|
97
|
+
const records = event?.Records ?? [];
|
|
98
|
+
for (const record of records) {
|
|
99
|
+
const attrs = record?.attributes ?? {};
|
|
100
|
+
const receiveCount = Number(attrs.ApproximateReceiveCount ?? 1);
|
|
101
|
+
const identity =
|
|
102
|
+
"messageId=" + (record?.messageId ?? "unknown") +
|
|
103
|
+
" source=" + (attrs.DeadLetterQueueSourceArn ?? record?.eventSourceARN ?? "unknown") +
|
|
104
|
+
" receiveCount=" + receiveCount +
|
|
105
|
+
" bodyBytes=" + (record?.body?.length ?? 0);
|
|
106
|
+
if (receiveCount <= 1) {
|
|
107
|
+
console.error("DEAD LETTER ITEM: " + identity, JSON.stringify(record));
|
|
108
|
+
} else {
|
|
109
|
+
console.error("DEAD LETTER REDELIVERY: " + identity);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
72
112
|
throw new Error(
|
|
73
|
-
"Dead-lettered " +
|
|
113
|
+
"Dead-lettered " + records.length +
|
|
74
114
|
" message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
|
|
75
115
|
);
|
|
76
116
|
};`
|
|
@@ -89,6 +129,24 @@ let layers =
|
|
|
89
129
|
->Option.getOr([])
|
|
90
130
|
->Pulumi.Input.make
|
|
91
131
|
|
|
132
|
+
// This is the one Lambda in the framework built by hand rather than through
|
|
133
|
+
// `RuntimeEnvironment_Lambda`, and it was the one Lambda whose logs Lambda
|
|
134
|
+
// auto-created a group for — which carries no retention, so every byte this
|
|
135
|
+
// handler ever wrote was kept forever. On the estate that surfaced it, the three
|
|
136
|
+
// such groups held 1.3 GB of a redelivery loop. Same managed group, same tiering
|
|
137
|
+
// as every other handler.
|
|
138
|
+
let logGroup = Util_LambdaLogging.makeManagedLogGroup(
|
|
139
|
+
~name,
|
|
140
|
+
~tags=AWS.Tags.make(
|
|
141
|
+
~name=`${name}LogGroup`,
|
|
142
|
+
~kind=ReventlessCore.ComponentType.Plugin,
|
|
143
|
+
~role=Logs,
|
|
144
|
+
~scope=Plugin,
|
|
145
|
+
),
|
|
146
|
+
~opts,
|
|
147
|
+
(),
|
|
148
|
+
)
|
|
149
|
+
|
|
92
150
|
let handler = Lambda.Function.make(
|
|
93
151
|
~name,
|
|
94
152
|
~args={
|
|
@@ -109,6 +167,7 @@ let handler = Lambda.Function.make(
|
|
|
109
167
|
]),
|
|
110
168
|
}: Lambda.Function.functionEnvironment
|
|
111
169
|
)->Pulumi.Input.make,
|
|
170
|
+
loggingConfig: ?Util_LambdaLogging.loggingConfigFor(logGroup),
|
|
112
171
|
},
|
|
113
172
|
~opts,
|
|
114
173
|
)
|
|
@@ -124,9 +183,7 @@ ReventlessCore.Monitoring.notify(
|
|
|
124
183
|
~kind=DeadLetterSink,
|
|
125
184
|
~name,
|
|
126
185
|
~component=deadLetterResource,
|
|
127
|
-
|
|
128
|
-
// Lambda auto-creates from the physical name.
|
|
129
|
-
~logLocator=Util_LambdaLogging.logLocatorFor(~logGroup=None, ~physicalName=deadLetterResource.name),
|
|
186
|
+
~logLocator=Util_LambdaLogging.logLocatorFor(~logGroup, ~physicalName=deadLetterResource.name),
|
|
130
187
|
)
|
|
131
188
|
|
|
132
189
|
let lambda = handler->Pulumi.Output.make
|
|
@@ -25,7 +25,7 @@ function queueTags(queueName) {
|
|
|
25
25
|
let queue = new (Aws.sqs.Queue)(name, {
|
|
26
26
|
messageRetentionSeconds: 1209600,
|
|
27
27
|
tags: queueTags(name),
|
|
28
|
-
visibilityTimeoutSeconds:
|
|
28
|
+
visibilityTimeoutSeconds: 900,
|
|
29
29
|
sqsManagedSseEnabled: false
|
|
30
30
|
});
|
|
31
31
|
|
|
@@ -34,7 +34,7 @@ let fifoQueue = new (Aws.sqs.Queue)(nameFifo, {
|
|
|
34
34
|
fifoQueue: true,
|
|
35
35
|
messageRetentionSeconds: 1209600,
|
|
36
36
|
tags: queueTags(nameFifo),
|
|
37
|
-
visibilityTimeoutSeconds:
|
|
37
|
+
visibilityTimeoutSeconds: 900,
|
|
38
38
|
sqsManagedSseEnabled: false
|
|
39
39
|
});
|
|
40
40
|
|
|
@@ -47,9 +47,23 @@ let opts = {
|
|
|
47
47
|
let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name, "Plugin", "Identity", "Plugin", undefined, undefined, undefined, undefined), opts);
|
|
48
48
|
|
|
49
49
|
let entryPointCode = `export const handler = async (event) => {
|
|
50
|
-
|
|
50
|
+
const records = event?.Records ?? [];
|
|
51
|
+
for (const record of records) {
|
|
52
|
+
const attrs = record?.attributes ?? {};
|
|
53
|
+
const receiveCount = Number(attrs.ApproximateReceiveCount ?? 1);
|
|
54
|
+
const identity =
|
|
55
|
+
"messageId=" + (record?.messageId ?? "unknown") +
|
|
56
|
+
" source=" + (attrs.DeadLetterQueueSourceArn ?? record?.eventSourceARN ?? "unknown") +
|
|
57
|
+
" receiveCount=" + receiveCount +
|
|
58
|
+
" bodyBytes=" + (record?.body?.length ?? 0);
|
|
59
|
+
if (receiveCount <= 1) {
|
|
60
|
+
console.error("DEAD LETTER ITEM: " + identity, JSON.stringify(record));
|
|
61
|
+
} else {
|
|
62
|
+
console.error("DEAD LETTER REDELIVERY: " + identity);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
51
65
|
throw new Error(
|
|
52
|
-
"Dead-lettered " +
|
|
66
|
+
"Dead-lettered " + records.length +
|
|
53
67
|
" message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
|
|
54
68
|
);
|
|
55
69
|
};`;
|
|
@@ -64,6 +78,8 @@ let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(entryPointCode);
|
|
|
64
78
|
|
|
65
79
|
let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
|
|
66
80
|
|
|
81
|
+
let logGroup = Util_LambdaLogging$ReventlessAws.makeManagedLogGroup(name, undefined, AWS_Tags$ReventlessAws.make(name + `LogGroup`, "Plugin", "Logs", "Plugin", undefined, undefined, undefined, undefined), opts, undefined);
|
|
82
|
+
|
|
67
83
|
let handler = new (Aws.lambda.Function)(name, {
|
|
68
84
|
handler: "index.handler",
|
|
69
85
|
runtime: "nodejs22.x",
|
|
@@ -82,12 +98,13 @@ let handler = new (Aws.lambda.Function)(name, {
|
|
|
82
98
|
Util_LambdaLogging$ReventlessAws.logLevelEntry()
|
|
83
99
|
])
|
|
84
100
|
},
|
|
85
|
-
sourceCodeHash: sourceCodeHash
|
|
101
|
+
sourceCodeHash: sourceCodeHash,
|
|
102
|
+
loggingConfig: Util_LambdaLogging$ReventlessAws.loggingConfigFor(logGroup)
|
|
86
103
|
}, opts);
|
|
87
104
|
|
|
88
105
|
let deadLetterResource = Util_Lambda$ReventlessAws.functionToResource(AWS_Tags$ReventlessAws.make(name, "Plugin", "DeadLetter", "Plugin", undefined, undefined, undefined, undefined), handler);
|
|
89
106
|
|
|
90
|
-
Monitoring$ReventlessCore.notify("DeadLetterSink", name, deadLetterResource, Util_LambdaLogging$ReventlessAws.logLocatorFor(
|
|
107
|
+
Monitoring$ReventlessCore.notify("DeadLetterSink", name, deadLetterResource, Util_LambdaLogging$ReventlessAws.logLocatorFor(logGroup, deadLetterResource.name));
|
|
91
108
|
|
|
92
109
|
let lambda = Pulumi.output(handler);
|
|
93
110
|
|
|
@@ -164,11 +181,14 @@ Pulumi.all([
|
|
|
164
181
|
|
|
165
182
|
let retentionSeconds = 1209600;
|
|
166
183
|
|
|
184
|
+
let visibilityTimeoutSeconds = 900;
|
|
185
|
+
|
|
167
186
|
export {
|
|
168
187
|
name,
|
|
169
188
|
nameFifo,
|
|
170
189
|
queueTags,
|
|
171
190
|
retentionSeconds,
|
|
191
|
+
visibilityTimeoutSeconds,
|
|
172
192
|
queue,
|
|
173
193
|
fifoQueue,
|
|
174
194
|
opts,
|
|
@@ -178,6 +198,7 @@ export {
|
|
|
178
198
|
code,
|
|
179
199
|
sourceCodeHash,
|
|
180
200
|
layers,
|
|
201
|
+
logGroup,
|
|
181
202
|
handler,
|
|
182
203
|
deadLetterResource,
|
|
183
204
|
lambda,
|
|
@@ -93,10 +93,24 @@ let fields = (
|
|
|
93
93
|
~computed: array<(string, JSON.t)>,
|
|
94
94
|
~viewModes: option<array<Platform.viewMode>>=?,
|
|
95
95
|
~bakedManifest: option<Platform.bakedManifest>=?,
|
|
96
|
+
~uiSlotsFile: option<string>=?,
|
|
96
97
|
~shellConfig: option<dict<JSON.t>>=?,
|
|
97
98
|
): dict<JSON.t> => {
|
|
98
99
|
let out = computed->Dict.fromArray
|
|
99
100
|
|
|
101
|
+
// The shell imports the module this key names, and imports nothing when the
|
|
102
|
+
// key is absent — so writing the object without naming it here would ship a
|
|
103
|
+
// file nothing fetches. Computed rather than left to `shellConfig` for the
|
|
104
|
+
// reason `manifestUrl` is: the deploy decides where the object goes, and a
|
|
105
|
+
// passthrough pointing the shell elsewhere would be a 404 the shell is
|
|
106
|
+
// designed to survive quietly.
|
|
107
|
+
uiSlotsFile->Option.forEach(_ =>
|
|
108
|
+
out->Dict.set(
|
|
109
|
+
ReventlessCore.Platform_UiSlots.configKey,
|
|
110
|
+
JSON.Encode.string(ReventlessCore.Platform_UiSlots.url),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
100
114
|
// A declared bake is what turns the shell's non-elevated audience on: an
|
|
101
115
|
// operator keeps the admin queries, everyone else discovers from this file.
|
|
102
116
|
// Computed rather than left to `shellConfig` because the deploy is what
|
|
@@ -4,6 +4,7 @@ import * as Pervasives from "@rescript/runtime/lib/es6/Pervasives.js";
|
|
|
4
4
|
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
5
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
6
|
import * as Platform$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/Platform.res.mjs";
|
|
7
|
+
import * as Platform_UiSlots$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_UiSlots.res.mjs";
|
|
7
8
|
import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
|
|
8
9
|
|
|
9
10
|
let journeyManifestsKey = "journeyManifestUrls";
|
|
@@ -56,8 +57,11 @@ function subscriptionEndpoint(httpsEndpoint) {
|
|
|
56
57
|
return httpsEndpoint.replace("https://", "wss://").replace(".appsync-api.", ".appsync-realtime-api.");
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function fields(computed, viewModes, bakedManifest, shellConfig) {
|
|
60
|
+
function fields(computed, viewModes, bakedManifest, uiSlotsFile, shellConfig) {
|
|
60
61
|
let out = Object.fromEntries(computed);
|
|
62
|
+
Stdlib_Option.forEach(uiSlotsFile, param => {
|
|
63
|
+
out[Platform_UiSlots$ReventlessCore.configKey] = Platform_UiSlots$ReventlessCore.url;
|
|
64
|
+
});
|
|
61
65
|
Stdlib_Option.forEach(bakedManifest, bake => {
|
|
62
66
|
out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(bake.key);
|
|
63
67
|
let urls = Platform_BakedManifest$ReventlessCore.journeyUrls(bake);
|
|
@@ -73,6 +73,27 @@ let readJsonFileVerbatim = (~path: string, ~label: string): string => {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Read a file at deploy time and return its exact bytes as a string, with no
|
|
78
|
+
* opinion about what is in them. A missing file throws; a present one ships as
|
|
79
|
+
* written.
|
|
80
|
+
*
|
|
81
|
+
* The counterpart to `readJsonFileVerbatim` for content this deploy cannot
|
|
82
|
+
* validate. A JSON file either parses or it does not, and checking costs
|
|
83
|
+
* nothing. An ES module is only known to be good once a browser has evaluated
|
|
84
|
+
* it, and there is no cheap check in between: parsing it here would need a
|
|
85
|
+
* JavaScript parser this deploy does not have, and a syntax check would still
|
|
86
|
+
* say nothing about whether it registers anything. So the deploy checks the one
|
|
87
|
+
* thing it can — that the declared path names a file — and leaves the rest to
|
|
88
|
+
* the consumer, which reports what it could not use.
|
|
89
|
+
*/
|
|
90
|
+
let readFileVerbatim = (~path: string, ~label: string): string => {
|
|
91
|
+
if !NodeFs.existsSync(path) {
|
|
92
|
+
JsError.throwWithMessage(`${label}: file does not exist: ${path}`)
|
|
93
|
+
}
|
|
94
|
+
NodeFs.readFileSync(path)
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
/**
|
|
77
98
|
* Replace `/` and `.` so a path can be used as a Pulumi resource URN segment.
|
|
78
99
|
*/
|
|
@@ -65,6 +65,13 @@ function readJsonFileVerbatim(path, label) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function readFileVerbatim(path, label) {
|
|
69
|
+
if (!Nodefs.existsSync(path)) {
|
|
70
|
+
Stdlib_JsError.throwWithMessage(label + `: file does not exist: ` + path);
|
|
71
|
+
}
|
|
72
|
+
return Nodefs.readFileSync(path, "utf8");
|
|
73
|
+
}
|
|
74
|
+
|
|
68
75
|
function sanitizeName(relativePath) {
|
|
69
76
|
return relativePath.replaceAll("/", "-").replaceAll(".", "-");
|
|
70
77
|
}
|
|
@@ -139,6 +146,7 @@ export {
|
|
|
139
146
|
walkInto,
|
|
140
147
|
walk,
|
|
141
148
|
readJsonFileVerbatim,
|
|
149
|
+
readFileVerbatim,
|
|
142
150
|
sanitizeName,
|
|
143
151
|
extensionOf,
|
|
144
152
|
contentTypeFor,
|