Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ const makeHarness = () => {
const runtimeLive = Layer.succeed(AlertRuntime, {
now: Effect.sync(() => Date.now()),
makeUuid: () => crypto.randomUUID(),
fetch: globalThis.fetch,
deliveryTimeoutMs: () => 15_000,
})
const hazelOAuthLive = HazelOAuthService.layer.pipe(Layer.provide(Layer.mergeAll(envLive, testDb.layer)))
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/routes/v2/alerts.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ const makeHarness = (
const runtimeLive = Layer.succeed(AlertRuntime, {
now: Effect.sync(() => Date.now()),
makeUuid: () => crypto.randomUUID(),
fetch: globalThis.fetch,
deliveryTimeoutMs: () => 15_000,
})
const hazelOAuthLive =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,19 @@ import { dispatchDelivery as dispatchDeliveryRaw } from "./delivery/dispatch"
import { FetchHttpClient } from "effect/http"
import type { EffectTransportDeps } from "./delivery/Transport"

/** The runtime provides the HTTP client; each test passes its own `fetch`. */
const dispatchDelivery = (...args: Parameters<typeof dispatchDeliveryRaw>) =>
dispatchDeliveryRaw(...args).pipe(Effect.provide(FetchHttpClient.layer))
type DispatchArgs = Parameters<typeof dispatchDeliveryRaw>

/** The runtime provides the HTTP client; each test fakes the wire as `FetchHttpClient.Fetch`. */
const dispatchDelivery = (
context: DispatchArgs[0],
payloadJson: DispatchArgs[1],
fetchFn: typeof fetch,
...rest: [DispatchArgs[2], DispatchArgs[3], DispatchArgs[4], DispatchArgs[5]]
) =>
dispatchDeliveryRaw(context, payloadJson, ...rest).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.provideService(FetchHttpClient.Fetch, fetchFn),
)

/**
* Characterization tests: they pin what each provider ACTUALLY sends today,
Expand Down
22 changes: 16 additions & 6 deletions packages/backend/src/services/alerts/AlertDeliveryDispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,19 @@ import { resolveSignalDisplay } from "./alert-signal-display"
import { renderTemplate } from "./alert-templating/renderer"
import { DEFAULT_BODY_TEMPLATE, DEFAULT_TITLE_TEMPLATE } from "./alert-templating/defaultTemplates"

/** The runtime provides the HTTP client; each test passes its own `fetch`. */
const dispatchDelivery = (...args: Parameters<typeof dispatchDeliveryRaw>) =>
dispatchDeliveryRaw(...args).pipe(Effect.provide(FetchHttpClient.layer))
type DispatchArgs = Parameters<typeof dispatchDeliveryRaw>

/** The runtime provides the HTTP client; each test fakes the wire as `FetchHttpClient.Fetch`. */
const dispatchDelivery = (
context: DispatchArgs[0],
payloadJson: DispatchArgs[1],
fetchFn: typeof fetch,
...rest: [DispatchArgs[2], DispatchArgs[3], DispatchArgs[4], DispatchArgs[5]]
) =>
dispatchDeliveryRaw(context, payloadJson, ...rest).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.provideService(FetchHttpClient.Fetch, fetchFn),
)

/** Chat posts must not happen for these destinations. */
const failingChatPost = () =>
Expand Down Expand Up @@ -278,11 +288,11 @@ describe("dispatchDelivery", () => {
}),
)

it.effect("calls an unguarded transport's fetch detached from the runtime object", () =>
it.effect("calls an unguarded transport's fetch as a bare function", () =>
Effect.gen(function* () {
// Regression: the unguarded transports used to call `runtime.fetchFn(...)`,
// Regression: the unguarded transports once called `runtime.fetchFn(...)`,
// a method call that hands workerd's global `fetch` a `this` of the
// runtime object — "Illegal invocation", every such delivery dead.
// runtime object: "Illegal invocation", every such delivery dead.
// A `function` (not an arrow) is what makes `this` observable here.
let called = false
let receiver: typeof globalThis | undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ export const makeAlertDestinationDelivery = (options: {
dispatchDeliveryImpl(
context,
payloadJson,
options.runtime.fetch,
options.runtime.deliveryTimeoutMs(),
context.linkUrl,
composeChatUrl(context),
Expand Down
10 changes: 5 additions & 5 deletions packages/backend/src/services/alerts/AlertDestinationsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,10 +442,9 @@ export class AlertDestinationsService extends Context.Service<
}
const result = yield* verifyPagerDutyRoutingKey(
integrationKey,
runtime.fetch,
runtime.deliveryTimeoutMs(),
`maple-keycheck-${runtime.makeUuid()}`,
)
).pipe(Effect.provideService(HttpClient.HttpClient, httpClient))
if (result.status === "invalid") {
return yield* Effect.fail(
makeValidationError(`PagerDuty rejected this routing key: ${result.reason}`),
Expand All @@ -463,9 +462,8 @@ export class AlertDestinationsService extends Context.Service<
const result = yield* verifyTelegramCredentials(
botToken,
chatId,
runtime.fetch,
runtime.deliveryTimeoutMs(),
)
).pipe(Effect.provideService(HttpClient.HttpClient, httpClient))
if (result.status === "invalid") {
return yield* Effect.fail(makeValidationError(result.reason))
}
Expand All @@ -482,7 +480,9 @@ export class AlertDestinationsService extends Context.Service<
if (!TELEGRAM_BOT_TOKEN_PATTERN.test(trimmed)) {
return yield* Effect.fail(makeValidationError(TELEGRAM_MALFORMED_TOKEN_MESSAGE))
}
const result = yield* fetchTelegramChats(trimmed, runtime.fetch, runtime.deliveryTimeoutMs())
const result = yield* fetchTelegramChats(trimmed, runtime.deliveryTimeoutMs()).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
)
if (result.status === "invalid") return yield* Effect.fail(makeValidationError(result.reason))
return result.chats
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ afterEach(() => cleanupTestDbs(createdDbs))
const runtime: AlertRuntimeApi = {
now: Effect.succeed(NOW),
makeUuid: () => RULE,
fetch: globalThis.fetch,
deliveryTimeoutMs: () => 15_000,
}

Expand Down
2 changes: 0 additions & 2 deletions packages/backend/src/services/alerts/AlertRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@ export interface AlertRuntimeApi {
/** Current wall-clock time in epoch ms, sourced from Effect's `Clock` so tests drive it via `TestClock`. */
readonly now: Effect.Effect<number>
readonly makeUuid: () => string
readonly fetch: typeof fetch
readonly deliveryTimeoutMs: () => number
}

export class AlertRuntime extends Context.Reference<AlertRuntimeApi>("@maple/api/services/AlertRuntime", {
defaultValue: (): AlertRuntimeApi => ({
now: Clock.currentTimeMillis,
makeUuid: () => randomUUID(),
fetch: globalThis.fetch,
deliveryTimeoutMs: () => DELIVERY_TIMEOUT_MS_DEFAULT,
}),
}) {}
13 changes: 10 additions & 3 deletions packages/backend/src/services/alerts/AlertsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,14 @@ const defaultTestRuntime: AlertRuntimeApi = {
// TestClock.adjust. Real `fetch`/`Effect.timeout` settle on the live event loop.
now: Clock.currentTimeMillis,
makeUuid: () => crypto.randomUUID(),
fetch: globalThis.fetch,
deliveryTimeoutMs: () => 15_000,
}

interface TestOverrides extends Partial<AlertRuntimeApi> {
/** Provided as `FetchHttpClient.Fetch`, so every outbound call goes through it. */
readonly fetch?: typeof fetch
}

// The fixed epoch scheduler tests start TestClock at, mirroring the previous
// manual clock's default start time.
const DEFAULT_CLOCK_EPOCH_MS = 1_700_000_000_000
Expand Down Expand Up @@ -245,10 +249,11 @@ const stubOrgMembersService = (
const makeLayer = (
testDb: TestDb,
warehouseStub: WarehouseQueryServiceApi,
runtimeOverrides?: Partial<AlertRuntimeApi>,
overrides: TestOverrides = {},
emailStub?: (typeof EmailService)["Service"],
chatAlertPoster: Layer.Layer<ChatAlertPoster, never, Database | Env> = ChatAlertPoster.layer,
) => {
const { fetch: fetchImpl = globalThis.fetch, ...runtimeOverrides } = overrides
const configLive = makeConfig()
const envLive = Env.layer.pipe(Layer.provide(configLive))
const databaseLive = testDb.layer
Expand Down Expand Up @@ -312,7 +317,9 @@ const makeLayer = (
Layer.provide(alertReadModelsLive),
Layer.provide(alertRulesLive),
)
return Layer.mergeAll(alertDestinationsLive, alertReadModelsLive, alertRulesLive, alertsLive)
return Layer.mergeAll(alertDestinationsLive, alertReadModelsLive, alertRulesLive, alertsLive).pipe(
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchImpl)),
)
}

const asOrgId = Schema.decodeUnknownSync(OrgId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@ const make: Effect.Effect<
const result = yield* dispatchDeliveryImpl(
context,
payloadJson,
globalThis.fetch,
DELIVERY_TIMEOUT_MS,
request.linkUrl,
chatUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,19 @@ import { FetchHttpClient } from "effect/http"
import type { EffectTransportDeps } from "./Transport"
import type { DispatchContext } from "./context"

/** The runtime provides the HTTP client; each test passes its own `fetch`. */
const dispatchDelivery = (...args: Parameters<typeof dispatchDeliveryRaw>) =>
dispatchDeliveryRaw(...args).pipe(Effect.provide(FetchHttpClient.layer))
type DispatchArgs = Parameters<typeof dispatchDeliveryRaw>

/** The runtime provides the HTTP client; each test fakes the wire as `FetchHttpClient.Fetch`. */
const dispatchDelivery = (
context: DispatchArgs[0],
payloadJson: DispatchArgs[1],
fetchFn: typeof fetch,
...rest: [DispatchArgs[2], DispatchArgs[3], DispatchArgs[4], DispatchArgs[5]]
) =>
dispatchDeliveryRaw(context, payloadJson, ...rest).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.provideService(FetchHttpClient.Fetch, fetchFn),
)

/**
* The outbound provider call must be a **Client-kind span carrying
Expand Down
3 changes: 1 addition & 2 deletions packages/backend/src/services/alerts/delivery/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,13 @@ import type { DispatchContext, DispatchResult } from "./context"
export const dispatchDelivery = (
context: DispatchContext,
payloadJson: string,
fetchFn: typeof fetch,
timeoutMs: number,
linkUrl: string,
chatUrl: string,
/** `sendEmail` (the platform email channel) and `postChatAlert` (a chat connector). */
deps: EffectTransportDeps,
): Effect.Effect<DispatchResult, AlertDeliveryFailure, HttpClient.HttpClient> => {
const runtime: TransportRuntime = { fetchFn, timeoutMs }
const runtime: TransportRuntime = { timeoutMs }
/**
* Resolved once here rather than by each provider: the template is a
* property of the rule and the destination TYPE, not of the transport's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "@maple/domain/http"
import { Duration, Effect, Option, Result } from "effect"
import { constTrue } from "effect/Function"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/http"
import { HttpBody, HttpClient, HttpClientRequest } from "effect/http"
import type { HttpClientResponse } from "effect/http"
import { describeHttpClientError, guard } from "@maple/safe-fetch"
import type {
Expand All @@ -25,7 +25,6 @@ import type {
import type { DispatchResult } from "./context"

export interface TransportRuntime {
readonly fetchFn: typeof fetch
readonly timeoutMs: number
}

Expand Down Expand Up @@ -134,7 +133,6 @@ const sendHttp = Effect.fn("AlertDelivery.http", { kind: "client" })(function* (
// cancelled when `runHttpTransport`'s scope closes.
const scoped = HttpClient.withScope(client)
const response = yield* (spec.guarded ? guard(scoped) : scoped).execute(request).pipe(
Effect.provideService(FetchHttpClient.Fetch, runtime.fetchFn),
// The client's own span records `url.full`, and Discord, Hazel and
// Telegram carry their delivery token in the URL path. This span is the
// client span.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ChatOutboundError, chatConnectorId, type ChatAlertBlock, type ChatBlock
import { AlertDestinationId, ChatWorkspaceId, OrgId } from "@maple/domain/http"
import { assert, describe, it } from "@effect/vitest"
import { Effect, Schema } from "effect"
import { FetchHttpClient } from "effect/http"
import { chatDeliveryFailure } from "../../ChatAlertPoster"
import type { DispatchContext } from "../context"
import { dispatchDelivery } from "../dispatch"
Expand Down Expand Up @@ -180,14 +181,17 @@ describe("chat through dispatchDelivery", () => {
throw new Error("a chat destination made an HTTP request of its own")
}
return Effect.gen(function* () {
const result = yield* dispatchDelivery(context, "{}", fetchFn, 5_000, LINK, CHAT, {
const result = yield* dispatchDelivery(context, "{}", 5_000, LINK, CHAT, {
sendEmail: () => Effect.die("the chat transport sent an email"),
postChatAlert: (post) =>
Effect.sync(() => {
posts.push(post)
return { connectorName: "Test Chat", messageId: "message-1" }
}),
})
}).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.provideService(FetchHttpClient.Fetch, fetchFn),
)
assert.strictEqual(result.providerMessage, "Delivered to Test Chat #incidents")
assert.strictEqual(posts[0]?.workspaceId, WORKSPACE)
assert.include(cardOf(posts[0]?.blocks ?? []).title, "Checkout error rate")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Duration, Effect } from "effect"
import { HttpClient, HttpClientRequest } from "effect/http"
import { displayGroupKey, truncate } from "../../alert-formatting"
import type { HttpTransport, RenderInput, SecretConfigOf } from "../Transport"

Expand Down Expand Up @@ -84,25 +85,27 @@ export type PagerDutyKeyVerification =
*/
export const verifyPagerDutyRoutingKey = (
integrationKey: string,
fetchFn: typeof fetch,
timeoutMs: number,
dedupKey: string,
): Effect.Effect<PagerDutyKeyVerification> =>
Effect.tryPromise(() =>
fetchFn("https://events.pagerduty.com/v2/enqueue", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
routing_key: integrationKey,
event_action: "resolve",
dedup_key: dedupKey,
}),
}),
): Effect.Effect<PagerDutyKeyVerification, never, HttpClient.HttpClient> =>
HttpClient.execute(
HttpClientRequest.post("https://events.pagerduty.com/v2/enqueue").pipe(
HttpClientRequest.bodyText(
JSON.stringify({
routing_key: integrationKey,
event_action: "resolve",
dedup_key: dedupKey,
}),
"application/json",
),
),
).pipe(
Effect.flatMap((response) => {
if (response.ok) return Effect.succeed<PagerDutyKeyVerification>({ status: "valid" })
if (response.status >= 200 && response.status < 300) {
return Effect.succeed<PagerDutyKeyVerification>({ status: "valid" })
}
if (response.status === 400) {
return Effect.promise(() => response.text().catch(() => "")).pipe(
return Effect.orElseSucceed(response.text, () => "").pipe(
Effect.map((body): PagerDutyKeyVerification => {
const reason = truncate(body.trim().replace(/\s+/g, " "), 500)
return { status: "invalid", reason: reason || "Invalid routing key" }
Expand Down
Loading
Loading