Skip to content

Commit 8b64da9

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/light-theme
2 parents c73b4e7 + 722e240 commit 8b64da9

27 files changed

Lines changed: 564 additions & 143 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Organizations without billing alerts now get default spend alert thresholds, so you're notified before usage grows unexpectedly. The billing limit page no longer pre-selects an option before you've set a limit and prompts you to configure one. Alert previews now update immediately after you change your billing limit.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Container startup no longer prints database and ClickHouse connection strings (with credentials) to the logs.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Add metrics to the realtime backend that measure how often a single changed run is served to multiple subscriptions in one batch.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: supervisor
3+
type: improvement
4+
---
5+
6+
Improved supervisor observability: it now reports metrics for its outbound requests, making failed calls to upstream services easier to monitor.

apps/supervisor/src/index.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
isKubernetesEnvironment,
2323
} from "@trigger.dev/core/v3/serverOnly";
2424
import { createK8sApi, createApiserverMetricsFetcher } from "./clients/kubernetes.js";
25-
import { collectDefaultMetrics, Gauge, Histogram } from "prom-client";
25+
import { collectDefaultMetrics, Counter, Gauge, Histogram } from "prom-client";
2626
import { register } from "./metrics.js";
2727
import { PodCleaner } from "./services/podCleaner.js";
2828
import { FailedPodHandler } from "./services/failedPodHandler.js";
@@ -60,6 +60,21 @@ const workloadCreateDuration = new Histogram({
6060
registers: [register],
6161
});
6262

63+
const outboundRequestsTotal = new Counter({
64+
name: "supervisor_outbound_request_total",
65+
help: "Count of outbound HTTP requests from the supervisor, by target name, method, response status, and outcome (ok, http_error, invalid_response, network_error).",
66+
labelNames: ["name", "method", "status", "outcome"],
67+
registers: [register],
68+
});
69+
70+
const outboundRequestDuration = new Histogram({
71+
name: "supervisor_outbound_request_duration_seconds",
72+
help: "Duration of outbound HTTP requests from the supervisor, by target name and outcome. Includes the HTTP client's internal retries and backoff.",
73+
labelNames: ["name", "outcome"],
74+
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 11, 12.5, 15, 20, 30, 60],
75+
registers: [register],
76+
});
77+
6378
class ManagedSupervisor {
6479
private readonly workerSession: SupervisorSession;
6580
private readonly metricsServer?: HttpServer;
@@ -322,6 +337,10 @@ class ManagedSupervisor {
322337
runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED,
323338
heartbeatIntervalSeconds: env.TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS,
324339
sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS,
340+
onHttpRequestComplete: ({ name, method, status, outcome, durationMs }) => {
341+
outboundRequestsTotal.inc({ name, method, status, outcome });
342+
outboundRequestDuration.observe({ name, outcome }, durationMs / 1000);
343+
},
325344
preDequeue: async () => {
326345
// Synchronous, hot-path-safe cached read; false when no monitors are active.
327346
const skipForBackpressure = this.backpressureMonitors.some((m) => m.shouldSkipDequeue());
@@ -692,6 +711,18 @@ class ManagedSupervisor {
692711
headers.traceparent = traceparent;
693712
}
694713

714+
const requestStart = performance.now();
715+
const record = (
716+
status: string,
717+
outcome: "ok" | "http_error" | "invalid_response" | "network_error"
718+
) => {
719+
outboundRequestsTotal.inc({ name: "warm_start", method: "POST", status, outcome });
720+
outboundRequestDuration.observe(
721+
{ name: "warm_start", outcome },
722+
(performance.now() - requestStart) / 1000
723+
);
724+
};
725+
695726
try {
696727
const res = await fetch(warmStartUrlWithPath.href, {
697728
method: "POST",
@@ -700,8 +731,10 @@ class ManagedSupervisor {
700731
});
701732

702733
if (!res.ok) {
734+
record(String(res.status), "http_error");
703735
this.logger.error("Warm start failed", {
704736
runId: dequeuedMessage.run.id,
737+
statusCode: res.status,
705738
});
706739
return false;
707740
}
@@ -710,15 +743,19 @@ class ManagedSupervisor {
710743
const parsedData = z.object({ didWarmStart: z.boolean() }).safeParse(data);
711744

712745
if (!parsedData.success) {
746+
record(String(res.status), "invalid_response");
713747
this.logger.error("Warm start response invalid", {
714748
runId: dequeuedMessage.run.id,
715749
data,
716750
});
717751
return false;
718752
}
719753

754+
record(String(res.status), "ok");
755+
720756
return parsedData.data.didWarmStart;
721757
} catch (error) {
758+
record("none", "network_error");
722759
this.logger.error("Warm start error", {
723760
runId: dequeuedMessage.run.id,
724761
error,

apps/webapp/app/components/billing/BillingLimitConfigSection.tsx

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from "zod";
77
import { getBillingLimitMode } from "~/components/billing/billingAlertsFormat";
88
import { formatGracePeriodMs } from "~/components/billing/billingLimitFormat";
99
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
10+
import { Callout } from "~/components/primitives/Callout";
1011
import { Button } from "~/components/primitives/Buttons";
1112
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
1213
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -51,10 +52,14 @@ type BillingLimitActionData = {
5152

5253
export function isBillingLimitFormDirty(input: {
5354
billingLimit: BillingLimitResult;
54-
mode: "none" | "plan" | "custom";
55+
mode: "" | "none" | "plan" | "custom";
5556
customAmount: string;
5657
cancelInProgressRuns: boolean;
5758
}): boolean {
59+
if (input.mode === "") {
60+
return false;
61+
}
62+
5863
const needsInitialSave = !input.billingLimit.isConfigured;
5964
const savedMode = getBillingLimitMode(input.billingLimit);
6065
const savedCustomAmount =
@@ -75,7 +80,7 @@ export function isBillingLimitFormDirty(input: {
7580

7681
export function getBillingLimitFormLastSubmission(
7782
submission: BillingLimitActionData["submission"] | undefined,
78-
mode: "none" | "plan" | "custom",
83+
mode: "" | "none" | "plan" | "custom",
7984
isDirty: boolean
8085
) {
8186
if (!isDirty || !submission) {
@@ -111,17 +116,20 @@ export function BillingLimitConfigSection({
111116
: "";
112117
const savedCancelInProgressRuns = billingLimit.isConfigured && billingLimit.cancelInProgressRuns;
113118

114-
const [mode, setMode] = useState<"none" | "plan" | "custom">(savedMode);
119+
// Unconfigured limit starts with nothing selected.
120+
const resetMode: "" | "none" | "plan" | "custom" = billingLimit.isConfigured ? savedMode : "";
121+
122+
const [mode, setMode] = useState<"" | "none" | "plan" | "custom">(resetMode);
115123
const [customAmount, setCustomAmount] = useState(savedCustomAmount);
116124
const [cancelInProgressRuns, setCancelInProgressRuns] = useState(savedCancelInProgressRuns);
117125
const customAmountInputRef = useRef<HTMLInputElement>(null);
118126
const formRef = useRef<HTMLFormElement>(null);
119127

120128
useEffect(() => {
121-
setMode(savedMode);
129+
setMode(resetMode);
122130
setCustomAmount(savedCustomAmount);
123131
setCancelInProgressRuns(savedCancelInProgressRuns);
124-
}, [savedMode, savedCustomAmount, savedCancelInProgressRuns]);
132+
}, [resetMode, savedCustomAmount, savedCancelInProgressRuns]);
125133

126134
function handleModeChange(value: string) {
127135
const nextMode = value as typeof mode;
@@ -183,6 +191,13 @@ export function BillingLimitConfigSection({
183191
</Paragraph>
184192
</div>
185193

194+
{!billingLimit.isConfigured && (
195+
<Callout variant="warning" className="mb-3">
196+
Configure a monthly billing limit below to cap your spend, or set no limit to let runs
197+
keep going.
198+
</Callout>
199+
)}
200+
186201
<Form method="post" {...getFormProps(form)} ref={formRef}>
187202
<input type="hidden" name="intent" value="billing-limit" />
188203
<Fieldset>
@@ -283,7 +298,7 @@ export function BillingLimitConfigSection({
283298
</div>
284299
</RadioGroup>
285300

286-
{mode !== "none" && (
301+
{(mode === "plan" || mode === "custom") && (
287302
<CheckboxWithLabel
288303
className="mt-4"
289304
name="cancelInProgressRuns"
@@ -295,14 +310,16 @@ export function BillingLimitConfigSection({
295310
onChange={setCancelInProgressRuns}
296311
/>
297312
)}
298-
<FormButtons
299-
className={isDirty ? undefined : "invisible"}
300-
confirmButton={
301-
<Button type="submit" variant="primary/small" disabled={!isDirty}>
302-
Save billing limit
303-
</Button>
304-
}
305-
/>
313+
{mode !== "" && (
314+
<FormButtons
315+
className={isDirty ? undefined : "invisible"}
316+
confirmButton={
317+
<Button type="submit" variant="primary/small" disabled={!isDirty}>
318+
Save billing limit
319+
</Button>
320+
}
321+
/>
322+
)}
306323
</Fieldset>
307324
</Form>
308325
</div>

apps/webapp/app/components/billing/billingAlertsFormat.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ export function isLegacyDollarAmountField(
208208
return false;
209209
}
210210

211+
// The exact $1 absolute base marker always wins, even with levels below 100 (e.g. a $5 alert).
212+
if (rawAmount === ABSOLUTE_ALERT_BASE_CENTS) {
213+
return false;
214+
}
215+
211216
if (!Number.isFinite(rawAmount) || rawAmount < 10) {
212217
return false;
213218
}
@@ -315,8 +320,9 @@ export function getAlertPreviewLimitCents(
315320
planLimitCents: number
316321
): number {
317322
const amountCents = getSavedAlertAmountCents(alerts);
323+
// Percentages always apply to the current limit, not the base stored at last save.
318324
if (amountCents > 0 && percentageAlertLevelsToUiThresholds(alerts.alertLevels).length > 0) {
319-
return amountCents;
325+
return effectiveLimitCents;
320326
}
321327
if (percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents)) {
322328
return amountCents;

apps/webapp/app/models/organization.server.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,22 @@ import type {
66
RuntimeEnvironment,
77
User,
88
} from "@trigger.dev/database";
9+
import { tryCatch } from "@trigger.dev/core/utils";
910
import { customAlphabet } from "nanoid";
1011
import { generate } from "random-words";
1112
import slug from "slug";
1213
import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
1314
import { env } from "~/env.server";
1415
import { featuresForUrl } from "~/features.server";
1516
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
16-
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
17+
import {
18+
getDefaultEnvironmentConcurrencyLimit,
19+
isBillingConfigured,
20+
setBillingAlert,
21+
} from "~/services/platform.v3.server";
22+
import { buildDefaultBillingAlerts } from "~/services/billingAlertsDefaults.server";
1723
import { enqueueAttioWorkspaceSync } from "~/services/attio.server";
24+
import { logger } from "~/services/logger.server";
1825
import {
1926
applyBillingLimitPauseAfterEnvCreate,
2027
getInitialEnvPauseStateForBillingLimit,
@@ -122,9 +129,39 @@ export async function createOrganization(
122129
adminUserId: userId,
123130
});
124131

132+
// Awaited so the seed can't land after the user's first alert edit.
133+
await seedDefaultBillingAlerts(organization.id);
134+
125135
return { ...organization };
126136
}
127137

138+
// The platform client has no request timeout; don't let a slow billing backend stall org creation.
139+
const SEED_ALERTS_TIMEOUT_MS = 5_000;
140+
141+
/** Seed default billing alerts for a new org. Never fails org creation. */
142+
async function seedDefaultBillingAlerts(organizationId: string): Promise<void> {
143+
if (!isBillingConfigured()) {
144+
return;
145+
}
146+
147+
let timer: NodeJS.Timeout | undefined;
148+
const timeout = new Promise<never>((_, reject) => {
149+
timer = setTimeout(() => reject(new Error("Timed out")), SEED_ALERTS_TIMEOUT_MS);
150+
});
151+
152+
const [error] = await tryCatch(
153+
Promise.race([setBillingAlert(organizationId, buildDefaultBillingAlerts()), timeout]).finally(
154+
() => clearTimeout(timer)
155+
)
156+
);
157+
if (error) {
158+
logger.warn("Failed to seed default billing alerts for new org", {
159+
organizationId,
160+
error: error instanceof Error ? error.message : error,
161+
});
162+
}
163+
}
164+
128165
export async function createEnvironment({
129166
organization,
130167
project,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { UpdateBillingAlertsRequest } from "@trigger.dev/platform";
2+
import { ABSOLUTE_ALERT_BASE_CENTS } from "~/components/billing/billingAlertsFormat";
3+
4+
/** Default absolute alert thresholds in dollars. */
5+
const DEFAULT_ALERT_THRESHOLD_DOLLARS = [5, 100, 500, 1000, 2500];
6+
7+
/**
8+
* Alerts fire at `usage / amount >= level`; amount = 100 cents makes levels
9+
* absolute dollar thresholds. Empty emails fall back to org admins.
10+
*/
11+
export function buildDefaultBillingAlerts(): UpdateBillingAlertsRequest {
12+
return {
13+
amount: ABSOLUTE_ALERT_BASE_CENTS,
14+
emails: [],
15+
alertLevels: [...DEFAULT_ALERT_THRESHOLD_DOLLARS],
16+
};
17+
}

apps/webapp/app/services/realtime/envChangeRouter.server.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ export type EnvChangeRouterOptions = {
5151
/** Observability: a buffered record was evicted. `cap` evictions mean the env churns more
5252
* runs inside the window than the buffer holds (the replay guarantee is degrading). */
5353
onReplayEviction?: (reason: "cap" | "window") => void;
54+
/** Observability: per-batch emission fan-out. `deliveries` = total (feed,run) rows matched and
55+
* resolved to feeds this batch. It is an upper bound on the per-feed wire serializations, since
56+
* the client working-set diff drops already-seen rows before encoding. `distinctRuns` = distinct
57+
* (columnSig,runId) among them (the serialize-once-per-batch floor). `deliveries / distinctRuns`
58+
* is the average number of feeds a changed run is delivered to; a shared-serialization step would
59+
* save at most `deliveries - distinctRuns` encodings. */
60+
onEmissionFanout?: (stats: { distinctRuns: number; deliveries: number; feeds: number }) => void;
5461
/** Read-your-writes gate over the replica: delays wake-path hydrates until the replica
5562
* should have applied the change (record.updatedAtMs + lag + margin), and re-hydrates
5663
* rows the tripwire still finds stale. Omit to hydrate immediately (legacy behavior). */
@@ -609,6 +616,8 @@ export class EnvChangeRouter {
609616

610617
// 4. Assemble each feed's matched rows (post-filtering tag feeds against the
611618
// authoritative hydrated row) and resolve its pending wait.
619+
let deliveries = 0;
620+
const distinctRunKeys = new Set<string>();
612621
for (const [feed, runIds] of matchedRunIdsByFeed) {
613622
if (!feed.resolve) {
614623
continue; // stopped waiting while we hydrated; its next poll/backstop covers it
@@ -631,10 +640,22 @@ export class EnvChangeRouter {
631640

632641
if (rows.length > 0) {
633642
feed.resolve({ reason: "notify", rows });
643+
deliveries += rows.length;
644+
for (const matched of rows) {
645+
distinctRunKeys.add(`${feed.columnSig}${matched.row.id}`);
646+
}
634647
}
635648
// No surviving rows (e.g. a partial-record candidate that didn't actually match):
636649
// leave the feed waiting; nothing relevant changed for it.
637650
}
651+
652+
if (deliveries > 0) {
653+
this.options.onEmissionFanout?.({
654+
distinctRuns: distinctRunKeys.size,
655+
deliveries,
656+
feeds: matchedRunIdsByFeed.size,
657+
});
658+
}
638659
}
639660

640661
/** Runs whose hydrated row is provably behind its record's watermark (stale content),

0 commit comments

Comments
 (0)