Skip to content

Commit 2b62610

Browse files
committed
feat(showcase/harness): label every Slack alert with its source environment
Prefix every harness-dispatched alert with a `[staging]`/`[production]`/ `[unknown]` source-env tag so operators triaging a red probe know which deploy environment is affected. The label is derived in the orchestrator from SHOWCASE_ENV ?? RAILWAY_ENVIRONMENT_NAME ?? "unknown" and applied at the single renderer chokepoint (covering per-key, cron, and on-error dispatch) plus the aggregation flush path that bypasses the renderer, via a shared sourceEnvPrefix helper so the two paths never drift. A missing env var surfaces as a visible [unknown] rather than a silent un-prefixed alert.
1 parent 283b1f8 commit 2b62610

6 files changed

Lines changed: 222 additions & 37 deletions

File tree

showcase/harness/src/alerts/alert-engine.test.ts

Lines changed: 99 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,50 @@ describe("alert-engine", () => {
136136
expect(tgt.sent).toHaveLength(1);
137137
});
138138

139+
it("prefixes a dispatched alert with the staging source-env label", async () => {
140+
const e = engine({
141+
env: { dashboardUrl: "https://d", repo: "r/r", sourceEnv: "staging" },
142+
});
143+
e.start();
144+
e.reload([baseRule()]);
145+
bus.emit("status.changed", {
146+
outcome: {
147+
previousState: "green",
148+
newState: "red",
149+
transition: "green_to_red",
150+
failCount: 1,
151+
firstFailureAt: "2026-04-20T00:00:00Z",
152+
},
153+
result: probeRes("red"),
154+
});
155+
await new Promise((r) => setImmediate(r));
156+
expect(tgt.sent).toHaveLength(1);
157+
const text = (tgt.sent[0] as { payload: { text: string } }).payload.text;
158+
expect(text).toBe("[staging] RED mastra");
159+
});
160+
161+
it("prefixes a dispatched alert with the production source-env label", async () => {
162+
const e = engine({
163+
env: { dashboardUrl: "https://d", repo: "r/r", sourceEnv: "production" },
164+
});
165+
e.start();
166+
e.reload([baseRule()]);
167+
bus.emit("status.changed", {
168+
outcome: {
169+
previousState: "green",
170+
newState: "red",
171+
transition: "green_to_red",
172+
failCount: 1,
173+
firstFailureAt: "2026-04-20T00:00:00Z",
174+
},
175+
result: probeRes("red"),
176+
});
177+
await new Promise((r) => setImmediate(r));
178+
expect(tgt.sent).toHaveLength(1);
179+
const text = (tgt.sent[0] as { payload: { text: string } }).payload.text;
180+
expect(text).toBe("[production] RED mastra");
181+
});
182+
139183
it("does NOT dispatch unrelated transition", async () => {
140184
const e = engine();
141185
e.start();
@@ -349,7 +393,7 @@ describe("alert-engine", () => {
349393
await new Promise((r) => setImmediate(r));
350394
expect(tgt.sent).toHaveLength(1);
351395
const payload = (tgt.sent[0] as { payload: { text: string } }).payload.text;
352-
expect(payload).toBe("!CH critical");
396+
expect(payload).toBe("[unknown] !CH critical");
353397
});
354398

355399
it("on_error rule fires on transition===error", async () => {
@@ -378,7 +422,7 @@ describe("alert-engine", () => {
378422
await new Promise((r) => setImmediate(r));
379423
expect(tgt.sent).toHaveLength(1);
380424
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
381-
"ERR: boom",
425+
"[unknown] ERR: boom",
382426
);
383427
});
384428

@@ -413,7 +457,7 @@ describe("alert-engine", () => {
413457
await new Promise((r) => setImmediate(r));
414458
expect(tgt.sent).toHaveLength(1);
415459
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
416-
"MID",
460+
"[unknown] MID",
417461
);
418462
});
419463

@@ -452,7 +496,7 @@ describe("alert-engine", () => {
452496
await new Promise((r) => setImmediate(r));
453497
expect(tgt.sent).toHaveLength(1);
454498
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
455-
"1 drifted",
499+
"[unknown] 1 drifted",
456500
);
457501
});
458502

@@ -491,7 +535,7 @@ describe("alert-engine", () => {
491535
await new Promise((r) => setImmediate(r));
492536
expect(tgt.sent).toHaveLength(1);
493537
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
494-
"1 unwired",
538+
"[unknown] 1 unwired",
495539
);
496540
});
497541

@@ -542,7 +586,7 @@ describe("alert-engine", () => {
542586
// must be empty — this is what we're actually testing.
543587
expect(tgt.sent).toHaveLength(1);
544588
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
545-
"",
589+
"[unknown] ",
546590
);
547591
});
548592

@@ -909,7 +953,7 @@ describe("alert-engine (additional behaviors)", () => {
909953
await new Promise((r) => setImmediate(r));
910954
expect(tgt.sent).toHaveLength(1);
911955
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
912-
"DRIFT 2",
956+
"[unknown] DRIFT 2",
913957
);
914958
});
915959

@@ -1002,7 +1046,7 @@ describe("alert-engine (additional behaviors)", () => {
10021046
await new Promise((r) => setImmediate(r));
10031047
expect(tgt.sent).toHaveLength(1);
10041048
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
1005-
"critical",
1049+
"[unknown] critical",
10061050
);
10071051
});
10081052

@@ -1042,7 +1086,7 @@ describe("alert-engine (additional behaviors)", () => {
10421086
await new Promise((r) => setImmediate(r));
10431087
expect(tgt.sent).toHaveLength(1);
10441088
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
1045-
"expected=17 actual=14 drift=3",
1089+
"[unknown] expected=17 actual=14 drift=3",
10461090
);
10471091
});
10481092

@@ -1165,7 +1209,7 @@ describe("alert-engine (additional behaviors)", () => {
11651209
expect(tgt.sent).toHaveLength(3);
11661210
for (const s of tgt.sent) {
11671211
expect((s as { payload: { text: string } }).payload.text).toBe(
1168-
"Ecritical",
1212+
"[unknown] Ecritical",
11691213
);
11701214
}
11711215
});
@@ -1306,7 +1350,7 @@ describe("alert-engine (additional behaviors)", () => {
13061350
await new Promise((r) => setImmediate(r));
13071351
expect(tgt.sent).toHaveLength(1);
13081352
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
1309-
"ERR",
1353+
"[unknown] ERR",
13101354
);
13111355
});
13121356

@@ -1358,7 +1402,7 @@ describe("alert-engine (additional behaviors)", () => {
13581402
await new Promise((r) => setImmediate(r));
13591403
expect(tgt.sent).toHaveLength(1);
13601404
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
1361-
"GATED",
1405+
"[unknown] GATED",
13621406
);
13631407
});
13641408

@@ -1425,7 +1469,7 @@ describe("alert-engine (additional behaviors)", () => {
14251469
await new Promise((r) => setImmediate(r));
14261470
expect(tgt.sent).toHaveLength(1);
14271471
expect((tgt.sent[0] as { payload: { text: string } }).payload.text).toBe(
1428-
"run=https://ci/99 id=99",
1472+
"[unknown] run=https://ci/99 id=99",
14291473
);
14301474
});
14311475
});
@@ -1568,7 +1612,7 @@ describe("alert-engine isRedTick flag (F1.6)", () => {
15681612
await new Promise((r) => setImmediate(r));
15691613
expect(sent).toHaveLength(1);
15701614
const text = (sent[0] as { payload: { text: string } }).payload.text;
1571-
expect(text).toBe(expected ? "RED" : "NOT");
1615+
expect(text).toBe(expected ? "[unknown] RED" : "[unknown] NOT");
15721616
e.stop();
15731617
}
15741618

@@ -1716,7 +1760,7 @@ describe("alert-engine dispatchCronAlert fresh-red + error state (F1.2/F1.3)", (
17161760
// as "green", skipping the error branch). Post-fix: routed via onError.
17171761
expect(sent).toHaveLength(1);
17181762
const text = (sent[0] as { payload: { text: string } }).payload.text;
1719-
expect(text).toBe("ERR: GHCR 500");
1763+
expect(text).toBe("[unknown] ERR: GHCR 500");
17201764
e.stop();
17211765
});
17221766
});
@@ -1784,7 +1828,7 @@ describe("alert-engine set_errored (F4.3)", () => {
17841828
await new Promise((r) => setImmediate(r));
17851829
expect(sent).toHaveLength(1);
17861830
expect((sent[0] as { payload: { text: string } }).payload.text).toBe(
1787-
"ERR 1",
1831+
"[unknown] ERR 1",
17881832
);
17891833
e.stop();
17901834
});
@@ -1894,7 +1938,7 @@ describe("alert-engine R7 (A1/A4/A5/A9)", () => {
18941938
});
18951939
await new Promise((r) => setImmediate(r));
18961940
expect(sent).toHaveLength(1);
1897-
expect((sent[0] as { payload: { text: string } }).payload.text).toBe("ERR");
1941+
expect((sent[0] as { payload: { text: string } }).payload.text).toBe("[unknown] ERR");
18981942
e.stop();
18991943
});
19001944

@@ -2296,7 +2340,7 @@ describe("alert-engine trigger.isRedTick coverage (HF-A2)", () => {
22962340
await new Promise((r) => setImmediate(r));
22972341
expect(sent).toHaveLength(1);
22982342
expect((sent[0] as { payload: { text: string } }).payload.text).toBe(
2299-
"RED",
2343+
"[unknown] RED",
23002344
);
23012345
e.stop();
23022346
});
@@ -2371,7 +2415,7 @@ describe("alert-engine escalation mention threading (HF-A3)", () => {
23712415
await new Promise((r) => setImmediate(r));
23722416
expect(sent).toHaveLength(1);
23732417
const text = (sent[0] as { payload: { text: string } }).payload.text;
2374-
expect(text).toBe("ping @oncall sev=error");
2418+
expect(text).toBe("[unknown] ping @oncall sev=error");
23752419
e.stop();
23762420
});
23772421

@@ -2436,7 +2480,7 @@ describe("alert-engine escalation mention threading (HF-A3)", () => {
24362480
await new Promise((r) => setImmediate(r));
24372481
expect(sent).toHaveLength(1);
24382482
const text = (sent[0] as { payload: { text: string } }).payload.text;
2439-
expect(text).toBe("m=[]");
2483+
expect(text).toBe("[unknown] m=[]");
24402484
e.stop();
24412485
});
24422486
});
@@ -2578,7 +2622,7 @@ describe("alert-engine dispatchCronAlert preserves error state (HF-A6)", () => {
25782622
expect(sent).toHaveLength(1);
25792623
const text = (sent[0] as { payload: { text: string } }).payload.text;
25802624
// Routed to onError; main template would print "main".
2581-
expect(text).toBe("ERR GHCR 500");
2625+
expect(text).toBe("[unknown] ERR GHCR 500");
25822626
e.stop();
25832627
});
25842628
});
@@ -2643,7 +2687,7 @@ describe("alert-engine dispatchCronAlert onError-only rule", () => {
26432687
await new Promise((r) => setImmediate(r));
26442688
expect(sent).toHaveLength(1);
26452689
const text = (sent[0] as { payload: { text: string } }).payload.text;
2646-
expect(text).toBe("errored! GHCR 500");
2690+
expect(text).toBe("[unknown] errored! GHCR 500");
26472691
e.stop();
26482692
});
26492693
});
@@ -2860,7 +2904,7 @@ describe("alert-engine R24: dispatchCronAlert bootstrap gate asymmetry", () => {
28602904
await new Promise((r) => setImmediate(r));
28612905
expect(sent).toHaveLength(1);
28622906
const text = (sent[0] as { payload: { text: string } }).payload.text;
2863-
expect(text).toBe("drifted 1");
2907+
expect(text).toBe("[unknown] drifted 1");
28642908
e.stop();
28652909
});
28662910

@@ -3548,4 +3592,36 @@ describe("alert-engine aggregation ingress (A1)", () => {
35483592
expect(tgt.sent).toHaveLength(1);
35493593
e.stop();
35503594
});
3595+
3596+
it("prefixes the fleet aggregation alert with the source-env label", async () => {
3597+
// The aggregation flush path bypasses renderer.render (it Mustache-renders
3598+
// the aggregation template directly), so this pins that it still carries
3599+
// the same source-env prefix as per-key alerts.
3600+
const e = engine({
3601+
env: { dashboardUrl: "https://d", repo: "r/r", sourceEnv: "production" },
3602+
});
3603+
e.start();
3604+
e.reload([aggregatedSmokeRule()]);
3605+
bus.emit("status.changed", {
3606+
outcome: {
3607+
previousState: "green",
3608+
newState: "red",
3609+
transition: "green_to_red",
3610+
failCount: 1,
3611+
firstFailureAt: "2026-04-20T00:00:00Z",
3612+
},
3613+
result: {
3614+
key: "smoke:mastra",
3615+
state: "red",
3616+
signal: { slug: "mastra", dimension: "smoke" },
3617+
observedAt: "2026-04-20T00:00:00Z",
3618+
},
3619+
});
3620+
await new Promise((r) => setImmediate(r));
3621+
await new Promise((r) => setImmediate(r));
3622+
expect(tgt.sent).toHaveLength(1);
3623+
const text = (tgt.sent[0] as { payload: { text: string } }).payload.text;
3624+
expect(text).toBe("[production] <!channel> fleet red 1");
3625+
e.stop();
3626+
});
35513627
});

showcase/harness/src/alerts/alert-engine.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Mustache from "mustache";
44
import type { TypedEventBus } from "../events/event-bus.js";
55
import type { MetricsRegistry } from "../http/metrics.js";
66
import type { Renderer } from "../render/renderer.js";
7+
import { sourceEnvPrefix } from "../render/renderer.js";
78
import type { CompiledRule } from "../rules/rule-loader.js";
89
import type { AlertStateStore } from "../storage/alert-state-store.js";
910
import { parseDuration, evalSuppress } from "./dsl.js";
@@ -52,7 +53,13 @@ export interface AlertEngineDeps {
5253
targets: Map<string, Target>;
5354
logger: Logger;
5455
now: () => Date;
55-
env: { dashboardUrl: string; repo: string };
56+
/**
57+
* `sourceEnv` labels the harness's own deploy environment ("staging" /
58+
* "production" / "unknown"), threaded into every rendered alert as a
59+
* source-env prefix so operators can tell which environment a red probe
60+
* came from. Derived from RAILWAY_ENVIRONMENT_NAME in orchestrator.ts.
61+
*/
62+
env: { dashboardUrl: string; repo: string; sourceEnv?: string };
5663
/** Milliseconds after boot during which green_to_red is suppressed. */
5764
bootstrapWindowMs?: number;
5865
/** HF-A1 — optional; see StatusReader JSDoc. */
@@ -1005,8 +1012,14 @@ export function createAlertEngine(deps: AlertEngineDeps): AlertEngine {
10051012
lastSignal,
10061013
groupValues: bucket.groupValues,
10071014
});
1015+
// Aggregation flush bypasses renderer.render (it Mustache-renders the
1016+
// aggregation template directly), so prepend the same source-env tag
1017+
// here to keep fleet alerts consistent with per-key/cron/on-error
1018+
// alerts. Uses the shared sourceEnvPrefix helper so the format never
1019+
// drifts between the two paths.
1020+
const prefixedText = sourceEnvPrefix(env.sourceEnv) + text;
10081021
const rendered = {
1009-
payload: { text } as Record<string, unknown>,
1022+
payload: { text: prefixedText } as Record<string, unknown>,
10101023
contentType: "application/json" as const,
10111024
};
10121025
const { results, allSucceeded } = await sendToTargets(rule, rendered);

showcase/harness/src/orchestrator.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,18 @@ export async function boot(opts: BootOptions = {}): Promise<{
201201
dashboardUrl:
202202
process.env.DASHBOARD_URL ?? "https://dashboard.showcase.copilotkit.ai",
203203
repo: process.env.REPO ?? "CopilotKit/CopilotKit",
204+
// Source-env label prefixed onto every Slack alert so operators can
205+
// tell whether a red probe came from staging or production. Railway
206+
// injects RAILWAY_ENVIRONMENT_NAME ("staging" / "production") into
207+
// every service at runtime; we prefer an explicit SHOWCASE_ENV
208+
// override for local/CI, then fall back to "unknown" (the renderer
209+
// surfaces the empty case as `[unknown]` rather than dropping the
210+
// tag — a missing env var must look like a visible gap, not a
211+
// legacy un-prefixed alert).
212+
sourceEnv:
213+
process.env.SHOWCASE_ENV ??
214+
process.env.RAILWAY_ENVIRONMENT_NAME ??
215+
"unknown",
204216
},
205217
bootstrapWindowMs: opts.bootstrapWindowMs,
206218
statusReader,

0 commit comments

Comments
 (0)