@smthrs/notifications 0.0.0-stage → 1.0.0-rc.13

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.
Files changed (146) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/LICENSE +21 -0
  3. package/README.md +93 -2
  4. package/dist/cjs/AlertPolicy.d.ts +284 -0
  5. package/dist/cjs/AlertPolicy.d.ts.map +1 -0
  6. package/dist/cjs/AlertPolicy.js +124 -0
  7. package/dist/cjs/AlertPolicy.js.map +7 -0
  8. package/dist/cjs/AlertRuntime.d.ts +90 -0
  9. package/dist/cjs/AlertRuntime.d.ts.map +1 -0
  10. package/dist/cjs/AlertRuntime.js +166 -0
  11. package/dist/cjs/AlertRuntime.js.map +7 -0
  12. package/dist/cjs/AlertSink.d.ts +101 -0
  13. package/dist/cjs/AlertSink.d.ts.map +1 -0
  14. package/dist/cjs/AlertSink.js +44 -0
  15. package/dist/cjs/AlertSink.js.map +7 -0
  16. package/dist/cjs/Alerts.d.ts +57 -0
  17. package/dist/cjs/Alerts.d.ts.map +1 -0
  18. package/dist/cjs/Alerts.js +46 -0
  19. package/dist/cjs/Alerts.js.map +7 -0
  20. package/dist/cjs/Notification.d.ts +214 -0
  21. package/dist/cjs/Notification.d.ts.map +1 -0
  22. package/dist/cjs/Notification.js +62 -0
  23. package/dist/cjs/Notification.js.map +7 -0
  24. package/dist/cjs/NotificationEvent.d.ts +214 -0
  25. package/dist/cjs/NotificationEvent.d.ts.map +1 -0
  26. package/dist/cjs/NotificationEvent.js +84 -0
  27. package/dist/cjs/NotificationEvent.js.map +7 -0
  28. package/dist/cjs/NotificationQueue.d.ts +194 -0
  29. package/dist/cjs/NotificationQueue.d.ts.map +1 -0
  30. package/dist/cjs/NotificationQueue.js +477 -0
  31. package/dist/cjs/NotificationQueue.js.map +7 -0
  32. package/dist/cjs/NotificationState.d.ts +169 -0
  33. package/dist/cjs/NotificationState.d.ts.map +1 -0
  34. package/dist/cjs/NotificationState.js +110 -0
  35. package/dist/cjs/NotificationState.js.map +7 -0
  36. package/dist/cjs/Projection.d.ts +30 -0
  37. package/dist/cjs/Projection.d.ts.map +1 -0
  38. package/dist/cjs/Projection.js +53 -0
  39. package/dist/cjs/Projection.js.map +7 -0
  40. package/dist/cjs/SteerPayload.d.ts +177 -0
  41. package/dist/cjs/SteerPayload.d.ts.map +1 -0
  42. package/dist/cjs/SteerPayload.js +61 -0
  43. package/dist/cjs/SteerPayload.js.map +7 -0
  44. package/dist/cjs/index.d.ts +41 -0
  45. package/dist/cjs/index.d.ts.map +1 -0
  46. package/dist/cjs/index.js +47 -0
  47. package/dist/cjs/index.js.map +7 -0
  48. package/dist/cjs/internal/foldCache.d.ts +36 -0
  49. package/dist/cjs/internal/foldCache.d.ts.map +1 -0
  50. package/dist/cjs/internal/foldCache.js +54 -0
  51. package/dist/cjs/internal/foldCache.js.map +7 -0
  52. package/dist/cjs/internal/replay.d.ts +22 -0
  53. package/dist/cjs/internal/replay.d.ts.map +1 -0
  54. package/dist/cjs/internal/replay.js +46 -0
  55. package/dist/cjs/internal/replay.js.map +7 -0
  56. package/dist/cjs/layerWebhook.d.ts +60 -0
  57. package/dist/cjs/layerWebhook.d.ts.map +1 -0
  58. package/dist/cjs/layerWebhook.js +145 -0
  59. package/dist/cjs/layerWebhook.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/esm/AlertPolicy.d.ts +284 -0
  62. package/dist/esm/AlertPolicy.d.ts.map +1 -0
  63. package/dist/esm/AlertPolicy.js +284 -0
  64. package/dist/esm/AlertPolicy.js.map +1 -0
  65. package/dist/esm/AlertRuntime.d.ts +90 -0
  66. package/dist/esm/AlertRuntime.d.ts.map +1 -0
  67. package/dist/esm/AlertRuntime.js +191 -0
  68. package/dist/esm/AlertRuntime.js.map +1 -0
  69. package/dist/esm/AlertSink.d.ts +101 -0
  70. package/dist/esm/AlertSink.d.ts.map +1 -0
  71. package/dist/esm/AlertSink.js +63 -0
  72. package/dist/esm/AlertSink.js.map +1 -0
  73. package/dist/esm/Alerts.d.ts +57 -0
  74. package/dist/esm/Alerts.d.ts.map +1 -0
  75. package/dist/esm/Alerts.js +57 -0
  76. package/dist/esm/Alerts.js.map +1 -0
  77. package/dist/esm/Notification.d.ts +214 -0
  78. package/dist/esm/Notification.d.ts.map +1 -0
  79. package/dist/esm/Notification.js +121 -0
  80. package/dist/esm/Notification.js.map +1 -0
  81. package/dist/esm/NotificationEvent.d.ts +214 -0
  82. package/dist/esm/NotificationEvent.d.ts.map +1 -0
  83. package/dist/esm/NotificationEvent.js +158 -0
  84. package/dist/esm/NotificationEvent.js.map +1 -0
  85. package/dist/esm/NotificationQueue.d.ts +194 -0
  86. package/dist/esm/NotificationQueue.d.ts.map +1 -0
  87. package/dist/esm/NotificationQueue.js +625 -0
  88. package/dist/esm/NotificationQueue.js.map +1 -0
  89. package/dist/esm/NotificationState.d.ts +169 -0
  90. package/dist/esm/NotificationState.d.ts.map +1 -0
  91. package/dist/esm/NotificationState.js +191 -0
  92. package/dist/esm/NotificationState.js.map +1 -0
  93. package/dist/esm/Projection.d.ts +30 -0
  94. package/dist/esm/Projection.d.ts.map +1 -0
  95. package/dist/esm/Projection.js +47 -0
  96. package/dist/esm/Projection.js.map +1 -0
  97. package/dist/esm/SteerPayload.d.ts +177 -0
  98. package/dist/esm/SteerPayload.d.ts.map +1 -0
  99. package/dist/esm/SteerPayload.js +139 -0
  100. package/dist/esm/SteerPayload.js.map +1 -0
  101. package/dist/esm/index.d.ts +41 -0
  102. package/dist/esm/index.d.ts.map +1 -0
  103. package/dist/esm/index.js +41 -0
  104. package/dist/esm/index.js.map +1 -0
  105. package/dist/esm/internal/foldCache.d.ts +36 -0
  106. package/dist/esm/internal/foldCache.d.ts.map +1 -0
  107. package/dist/esm/internal/foldCache.js +58 -0
  108. package/dist/esm/internal/foldCache.js.map +1 -0
  109. package/dist/esm/internal/replay.d.ts +22 -0
  110. package/dist/esm/internal/replay.d.ts.map +1 -0
  111. package/dist/esm/internal/replay.js +31 -0
  112. package/dist/esm/internal/replay.js.map +1 -0
  113. package/dist/esm/layerWebhook.d.ts +60 -0
  114. package/dist/esm/layerWebhook.d.ts.map +1 -0
  115. package/dist/esm/layerWebhook.js +186 -0
  116. package/dist/esm/layerWebhook.js.map +1 -0
  117. package/docs/README.md +146 -0
  118. package/docs/api.md +443 -0
  119. package/docs/concepts/admission-and-promotion.md +144 -0
  120. package/docs/concepts/alerting.md +138 -0
  121. package/docs/concepts/journal-records.md +132 -0
  122. package/docs/guides/alert-on-a-stuck-run.md +140 -0
  123. package/docs/guides/drain-at-a-turn-boundary.md +95 -0
  124. package/docs/guides/handle-a-full-queue.md +102 -0
  125. package/docs/guides/report-pending-notifications.md +92 -0
  126. package/docs/guides/send-alerts-to-a-webhook.md +155 -0
  127. package/docs/guides/steer-a-run.md +127 -0
  128. package/docs/guides/testing.md +161 -0
  129. package/docs/installation.md +123 -0
  130. package/docs/quickstart.md +159 -0
  131. package/docs/troubleshooting.md +254 -0
  132. package/package.json +182 -3
  133. package/src/AlertPolicy.ts +377 -0
  134. package/src/AlertRuntime.ts +280 -0
  135. package/src/AlertSink.ts +105 -0
  136. package/src/Alerts.ts +71 -0
  137. package/src/Notification.ts +171 -0
  138. package/src/NotificationEvent.ts +213 -0
  139. package/src/NotificationQueue.ts +856 -0
  140. package/src/NotificationState.ts +295 -0
  141. package/src/Projection.ts +48 -0
  142. package/src/SteerPayload.ts +198 -0
  143. package/src/index.ts +47 -0
  144. package/src/internal/foldCache.ts +72 -0
  145. package/src/internal/replay.ts +39 -0
  146. package/src/layerWebhook.ts +239 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The webhook alert sink: one POST per alert, keyed so the receiver can
3
+ * dedupe on it.
4
+ *
5
+ * The transport rules live here rather than beside the policy, because which
6
+ * endpoint is paged, over which scheme, carrying which credential is a
7
+ * deployment's decision and the delays are not.
8
+ *
9
+ * @since 1.0.0
10
+ */
11
+
12
+ import { Cause, Duration, Effect, Exit, Layer, Option, Tracer } from "effect"
13
+ import * as FetchHttpClient from "effect/http/FetchHttpClient"
14
+ import * as Headers from "effect/http/Headers"
15
+ import * as HttpClient from "effect/http/HttpClient"
16
+ import * as HttpClientRequest from "effect/http/HttpClientRequest"
17
+ import { alertId } from "./AlertPolicy.ts"
18
+ import { AlertError, Sink } from "./AlertSink.ts"
19
+
20
+ /**
21
+ * How long the webhook sink waits for an answer before it calls the page
22
+ * refused.
23
+ *
24
+ * @category constants
25
+ * @since 1.0.0
26
+ */
27
+ export const defaultWebhookTimeout: Duration.Duration = Duration.seconds(10)
28
+
29
+ /**
30
+ * The one check the webhook endpoint gets before an alert is ever built on
31
+ * it.
32
+ *
33
+ * The url is operator config rather than runtime input, so there is no SSRF
34
+ * vector to close here; the scheme bound is what keeps a credential the
35
+ * deployment set in `headers` from being handed to a scheme that is not HTTP
36
+ * at all. A `file:` or `javascript:` url is a config bug, and it fails the
37
+ * composition the way an impossible policy rule does. The error names no
38
+ * url: the value can carry basic-auth credentials, and an error is logged.
39
+ */
40
+ const webhookUrl = (raw: string): Effect.Effect<string, AlertError> =>
41
+ Effect.suspend(() => {
42
+ const misconfigured = new AlertError({
43
+ code: "sink_misconfigured",
44
+ message: "Alert webhook url must be an absolute http: or https: URL"
45
+ })
46
+ let parsed: URL
47
+ try {
48
+ parsed = new URL(raw)
49
+ } catch {
50
+ return Effect.fail(misconfigured)
51
+ }
52
+ return parsed.protocol === "http:" || parsed.protocol === "https:"
53
+ ? Effect.succeed(raw)
54
+ : Effect.fail(misconfigured)
55
+ })
56
+
57
+ /**
58
+ * Runs a webhook request with redirect following switched off at the fetch
59
+ * transport.
60
+ *
61
+ * The request carries whatever credential the deployment put in `headers`,
62
+ * and a transport that follows a 3xx re-sends those headers to an origin the
63
+ * deployment never named. Every other fetch default the composition set is
64
+ * kept; only the redirect mode is forced. A 3xx therefore surfaces to the
65
+ * status rule, which calls it `sink_rejected`: a redirecting pager endpoint
66
+ * is a misconfiguration to fix, not an address to forward credentials to.
67
+ */
68
+ const followingNoRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
69
+ Effect.flatMap(
70
+ Effect.serviceOption(FetchHttpClient.RequestInit),
71
+ (init) =>
72
+ Effect.provideService(effect, FetchHttpClient.RequestInit, {
73
+ ...(Option.isSome(init) ? init.value : {}),
74
+ redirect: "manual"
75
+ })
76
+ )
77
+
78
+ const rejected = (status: number): AlertError =>
79
+ new AlertError({ code: "sink_rejected", status, message: `Alert webhook answered ${status}` })
80
+
81
+ /**
82
+ * The span attributes of a delivery that say nothing of the url's path, query
83
+ * or userinfo: the method, the origin and the answer.
84
+ */
85
+ const originAttributes: ReadonlySet<string> = new Set([
86
+ "http.request.method",
87
+ "url.scheme",
88
+ "server.address",
89
+ "server.port",
90
+ "http.response.status_code",
91
+ "error.type"
92
+ ])
93
+
94
+ const isHeaderAttribute = (key: string): boolean =>
95
+ key.startsWith("http.request.header.") || key.startsWith("http.response.header.")
96
+
97
+ /**
98
+ * The headers of a redirect that name where the endpoint moved, which is the
99
+ * same secret under another address.
100
+ */
101
+ const redirectHeaderNames: ReadonlyArray<string> = ["location", "content-location"]
102
+
103
+ /**
104
+ * The tracer a delivery opens its HTTP span on: the caller's own, with nothing
105
+ * of the request left on the span.
106
+ *
107
+ * A webhook url is a credential. Slack, Discord and PagerDuty put the secret
108
+ * in the path, others take it in the query, and a url may carry basic-auth
109
+ * userinfo. The HTTP client writes the whole url on its span and again in the
110
+ * failure it ends the span with, and the answer or the transport error it ends
111
+ * with holds the request. So only the attributes that name the method, the
112
+ * origin and the answer are kept, with the headers a host chose to record, and
113
+ * the span ends with the outcome alone: nothing on success, the refusing
114
+ * status, or that the endpoint could not be reached.
115
+ */
116
+ const withoutRequest = (tracer: Tracer.Tracer): Tracer.Tracer =>
117
+ Tracer.make({
118
+ ...tracer,
119
+ span(options) {
120
+ const span = tracer.span(options)
121
+ const end = span.end.bind(span)
122
+ const attribute = span.attribute.bind(span)
123
+ span.attribute = (key, value) => {
124
+ if (originAttributes.has(key) || isHeaderAttribute(key)) attribute(key, value)
125
+ }
126
+ span.end = (endTime, exit) => {
127
+ if (exit._tag === "Success") return end(endTime, Exit.void)
128
+ if (Cause.hasInterruptsOnly(exit.cause)) return end(endTime, exit)
129
+ const status = span.attributes.get("http.response.status_code")
130
+ return end(
131
+ endTime,
132
+ Exit.fail(
133
+ typeof status === "number"
134
+ ? rejected(status)
135
+ : new AlertError({ code: "sink_unreachable", message: "Alert webhook could not be reached" })
136
+ )
137
+ )
138
+ }
139
+ return span
140
+ }
141
+ })
142
+
143
+ /**
144
+ * A sink that POSTs each alert to one webhook.
145
+ *
146
+ * The body is the alert plus its {@link alertId}, and the same id is sent as
147
+ * an `Idempotency-Key` header, because the package requires the receiver to
148
+ * dedupe on it and a body without it cannot. The header is set after the
149
+ * caller's headers, so it is the one this sink sends.
150
+ *
151
+ * The endpoint must be an absolute `http:` or `https:` URL, checked when the
152
+ * layer is built, and the request is made with redirect following disabled:
153
+ * the caller's headers can carry a credential, and a followed redirect would
154
+ * re-send them to an origin the deployment never named. A 3xx answer is a
155
+ * refusal like any other non-2xx, not a forwarding instruction. The sink owns
156
+ * its Fetch transport; an externally supplied `HttpClient` is not used.
157
+ * Each delivery has its own scope, which aborts the request and releases any
158
+ * unread response body on completion, failure, timeout, or interruption.
159
+ *
160
+ * The url is treated as a credential, because a webhook's secret usually is
161
+ * its path or query. The delivery's HTTP span is named for the method and
162
+ * records the scheme, host, port and answering status, never the path, the
163
+ * query or userinfo, and it fails with the same url-free reason the sink
164
+ * returns. A host that records HTTP headers on spans gets every name in
165
+ * `headers`, and a redirect's `location`, as `<redacted>`.
166
+ *
167
+ * A non-2xx response is a failure, not a delivery, and an endpoint that never
168
+ * answers is a failure after `timeout`. Paging is exactly the case where a
169
+ * silently dropped request is worse than a retry, and a hung request is worse
170
+ * than either: it is indistinguishable from silence, so it must not be
171
+ * possible to wait on one forever.
172
+ *
173
+ * @param options the endpoint, any headers it needs, and how long to wait
174
+ * @category layers
175
+ * @since 0.1.0
176
+ */
177
+ export const layerWebhook = (
178
+ options: {
179
+ readonly url: string
180
+ readonly headers?: Readonly<Record<string, string>> | undefined
181
+ readonly timeout?: Duration.Duration | undefined
182
+ }
183
+ ): Layer.Layer<Sink> =>
184
+ Layer.effect(
185
+ Sink,
186
+ Effect.gen(function*() {
187
+ const url = yield* Effect.orDie(webhookUrl(options.url))
188
+ const client = HttpClient.withScope(yield* HttpClient.HttpClient)
189
+ const timeout = options.timeout ?? defaultWebhookTimeout
190
+ // `Headers.isRedactedName` compares a string matcher against an
191
+ // already-lowercased header name without lowercasing the matcher.
192
+ const redactedNames = [
193
+ ...Object.keys(options.headers ?? {}).map((name) => name.toLowerCase()),
194
+ ...redirectHeaderNames
195
+ ]
196
+ return {
197
+ deliver: (alert) =>
198
+ client.execute(
199
+ HttpClientRequest.post(url).pipe(
200
+ (request) =>
201
+ options.headers === undefined ? request : HttpClientRequest.setHeaders(request, options.headers),
202
+ HttpClientRequest.setHeader("Idempotency-Key", alertId(alert)),
203
+ HttpClientRequest.bodyJsonUnsafe({ ...alert, alertId: alertId(alert) })
204
+ )
205
+ ).pipe(
206
+ followingNoRedirects,
207
+ Effect.updateService(Headers.CurrentRedactedNames, (names) => [...names, ...redactedNames]),
208
+ // A host may name HTTP spans for their url. This one is named for
209
+ // its method, as the client names it by default.
210
+ Effect.provideService(HttpClient.SpanNameGenerator, (request) => request.method),
211
+ Effect.updateService(Tracer.Tracer, withoutRequest),
212
+ Effect.flatMap((response) =>
213
+ response.status >= 200 && response.status < 300
214
+ ? Effect.void
215
+ : Effect.fail(rejected(response.status))
216
+ ),
217
+ Effect.timeout(timeout),
218
+ Effect.scoped,
219
+ Effect.catchTag("TimeoutError", () =>
220
+ Effect.fail(
221
+ new AlertError({
222
+ code: "sink_timeout",
223
+ message: `Alert webhook did not answer within ${Duration.toMillis(timeout)} ms`
224
+ })
225
+ )),
226
+ // The transport error holds the request, and the request holds the
227
+ // caller's headers. Only its reason tag crosses into the failure.
228
+ Effect.catchTag("HttpClientError", (cause) =>
229
+ Effect.fail(
230
+ new AlertError({
231
+ code: "sink_unreachable",
232
+ reason: cause.reason._tag,
233
+ message: "Alert webhook could not be reached"
234
+ })
235
+ ))
236
+ )
237
+ }
238
+ })
239
+ ).pipe(Layer.provide(FetchHttpClient.layer))