@obsunified/collector 2.2.0 → 2.2.3
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/dist/auth.js +1 -1
- package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
- package/dist/chunk-2FALTDCE.js.map +1 -0
- package/dist/index.d.ts +61 -3
- package/dist/index.js +10933 -9769
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
- package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
- package/src/auth/dashboard-auth.test.ts +61 -0
- package/src/auth/dashboard-auth.ts +17 -2
- package/src/lib/ai-store/calls.ts +43 -23
- package/src/lib/ai-store.ts +12 -1
- package/src/lib/analyses-runner.ts +44 -7
- package/src/lib/autosre-change-correlation.ts +39 -12
- package/src/lib/autosre-findings.ts +237 -44
- package/src/lib/autosre-ontology-cache.test.ts +78 -0
- package/src/lib/autosre-ontology.ts +52 -28
- package/src/lib/autosre-remediation-gate.ts +942 -348
- package/src/lib/housekeeping.test.ts +70 -0
- package/src/lib/housekeeping.ts +91 -83
- package/src/lib/projects-store.test.ts +38 -1
- package/src/lib/projects-store.ts +23 -4
- package/src/lib/sql-db-postgres.integration.test.ts +75 -2
- package/src/lib/store/ingest.ts +8 -56
- package/src/lib/usage-store.ts +9 -3
- package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -0
- package/src/migrations/alert-state-notified.test.ts +3 -3
- package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
- package/src/migrations-postgres/052_remediation_callback.sql +10 -0
- package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
- package/src/plugins/ai-call-by-id.test.ts +73 -0
- package/src/plugins/ai-receiver.ts +10 -0
- package/src/plugins/alerts-evaluator.ts +15 -3
- package/src/plugins/autosre-routes.ts +374 -108
- package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
- package/src/plugins/connected-routes/manifest.test.ts +142 -0
- package/src/plugins/connected-routes/manifest.ts +123 -6
- package/src/plugins/connected-routes-links.test.ts +96 -0
- package/src/plugins/connected-routes.test.ts +3 -3
- package/src/plugins/connected-routes.ts +56 -83
- package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
- package/src/plugins/evidence-retrieval-routes.ts +397 -25
- package/dist/chunk-KMEZKGSO.js.map +0 -1
|
@@ -789,3 +789,64 @@ describe("dashboard-auth middleware & programmatic token contract", () => {
|
|
|
789
789
|
});
|
|
790
790
|
});
|
|
791
791
|
});
|
|
792
|
+
|
|
793
|
+
describe("session cookie Secure attribute", () => {
|
|
794
|
+
const loginCookie = async (
|
|
795
|
+
url: string,
|
|
796
|
+
headers: Record<string, string>,
|
|
797
|
+
trustProxyHeaders: boolean | undefined,
|
|
798
|
+
): Promise<string> => {
|
|
799
|
+
const auth = createDashboardAuth({
|
|
800
|
+
password: TEST_PASSWORD,
|
|
801
|
+
token: TEST_TOKEN,
|
|
802
|
+
loginRateLimit: { trustProxyHeaders, warn: () => {} },
|
|
803
|
+
});
|
|
804
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
805
|
+
auth.registerRoutes(app);
|
|
806
|
+
const res = await app.fetch(
|
|
807
|
+
new Request(url, {
|
|
808
|
+
method: "POST",
|
|
809
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
810
|
+
body: JSON.stringify({ password: TEST_PASSWORD }),
|
|
811
|
+
}),
|
|
812
|
+
makeEnv(createTestDb()),
|
|
813
|
+
);
|
|
814
|
+
expect(res.status).toBe(200);
|
|
815
|
+
return res.headers.get("Set-Cookie") ?? "";
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
it("sets Secure for an https request URL", async () => {
|
|
819
|
+
expect(await loginCookie("https://x.test/auth/login", {}, false)).toContain(
|
|
820
|
+
"; Secure",
|
|
821
|
+
);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it("sets Secure for X-Forwarded-Proto: https when the proxy is trusted", async () => {
|
|
825
|
+
const cookie = await loginCookie(
|
|
826
|
+
"http://x.test/auth/login",
|
|
827
|
+
{ "x-forwarded-proto": "https" },
|
|
828
|
+
true,
|
|
829
|
+
);
|
|
830
|
+
expect(cookie).toContain("; Secure");
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("ignores X-Forwarded-Proto when the proxy is not trusted, and on plain http", async () => {
|
|
834
|
+
expect(
|
|
835
|
+
await loginCookie(
|
|
836
|
+
"http://x.test/auth/login",
|
|
837
|
+
{ "x-forwarded-proto": "https" },
|
|
838
|
+
undefined,
|
|
839
|
+
),
|
|
840
|
+
).not.toContain("Secure");
|
|
841
|
+
expect(
|
|
842
|
+
await loginCookie(
|
|
843
|
+
"http://x.test/auth/login",
|
|
844
|
+
{ "x-forwarded-proto": "http" },
|
|
845
|
+
true,
|
|
846
|
+
),
|
|
847
|
+
).not.toContain("Secure");
|
|
848
|
+
expect(
|
|
849
|
+
await loginCookie("http://x.test/auth/login", {}, true),
|
|
850
|
+
).not.toContain("Secure");
|
|
851
|
+
});
|
|
852
|
+
});
|
|
@@ -293,6 +293,21 @@ export function createDashboardAuth(config?: DashboardAuthConfig): {
|
|
|
293
293
|
: new LoginRateLimiter(config?.loginRateLimit ?? {});
|
|
294
294
|
let warnedUnkeyedClient = false;
|
|
295
295
|
|
|
296
|
+
// `Secure` when the request itself is https, or when the operator
|
|
297
|
+
// declared a trusted TLS-terminating proxy (`trustProxyHeaders`, i.e.
|
|
298
|
+
// TRUST_PROXY) that reports `X-Forwarded-Proto: https`.
|
|
299
|
+
const isSecureRequest = (
|
|
300
|
+
c: Context<{ Bindings: CollectorEnv }>,
|
|
301
|
+
limitConfig: { trustProxyHeaders?: boolean } | undefined,
|
|
302
|
+
): boolean =>
|
|
303
|
+
c.req.url.startsWith("https:") ||
|
|
304
|
+
(limitConfig?.trustProxyHeaders === true &&
|
|
305
|
+
c.req
|
|
306
|
+
.header("x-forwarded-proto")
|
|
307
|
+
?.split(",")[0]
|
|
308
|
+
?.trim()
|
|
309
|
+
.toLowerCase() === "https");
|
|
310
|
+
|
|
296
311
|
app.post("/auth/login", async (c) => {
|
|
297
312
|
const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
|
|
298
313
|
if (!configuredPassword) {
|
|
@@ -342,7 +357,7 @@ export function createDashboardAuth(config?: DashboardAuthConfig): {
|
|
|
342
357
|
const sig = await sign(payload, configuredPassword);
|
|
343
358
|
const cookieValue = `${payload}.${sig}`;
|
|
344
359
|
|
|
345
|
-
const isSecure = c
|
|
360
|
+
const isSecure = isSecureRequest(c, config?.loginRateLimit || undefined);
|
|
346
361
|
c.header(
|
|
347
362
|
"Set-Cookie",
|
|
348
363
|
`${SESSION_COOKIE}=${encodeURIComponent(cookieValue)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE}${isSecure ? "; Secure" : ""}`,
|
|
@@ -405,7 +420,7 @@ export function createDashboardAuth(config?: DashboardAuthConfig): {
|
|
|
405
420
|
});
|
|
406
421
|
|
|
407
422
|
app.post("/auth/logout", (c) => {
|
|
408
|
-
const isSecure = c
|
|
423
|
+
const isSecure = isSecureRequest(c, config?.loginRateLimit || undefined);
|
|
409
424
|
c.header(
|
|
410
425
|
"Set-Cookie",
|
|
411
426
|
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0${isSecure ? "; Secure" : ""}`,
|
|
@@ -53,6 +53,48 @@ export async function ingestAICallBatch(
|
|
|
53
53
|
await db.batch(batch);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function mapAICallRow(row: AICallRow): AICallRecord {
|
|
57
|
+
return {
|
|
58
|
+
projectId: row.project_id ?? "default",
|
|
59
|
+
callId: row.call_id,
|
|
60
|
+
traceId: row.trace_id,
|
|
61
|
+
spanId: row.span_id,
|
|
62
|
+
serviceName: row.service_name,
|
|
63
|
+
modelName: row.model_name,
|
|
64
|
+
provider: row.provider,
|
|
65
|
+
callType: row.call_type,
|
|
66
|
+
requestJson: row.request_json,
|
|
67
|
+
responseJson: row.response_json,
|
|
68
|
+
promptTokens: row.prompt_tokens,
|
|
69
|
+
completionTokens: row.completion_tokens,
|
|
70
|
+
totalCostUsd: row.total_cost_usd,
|
|
71
|
+
latencyMs: row.latency_ms,
|
|
72
|
+
isError: row.is_error === 1 || row.is_error === true,
|
|
73
|
+
errorMessage: row.error_message,
|
|
74
|
+
occurredAt: row.occurred_at,
|
|
75
|
+
receivedAt: row.received_at,
|
|
76
|
+
expiresAt: row.expires_at,
|
|
77
|
+
sessionId: row.session_id ?? null,
|
|
78
|
+
interactionId: row.interaction_id ?? null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One call by id, scoped to the project; `null` when absent. */
|
|
83
|
+
export async function getAICallById(
|
|
84
|
+
db: SqlDb,
|
|
85
|
+
projectId: string,
|
|
86
|
+
callId: string,
|
|
87
|
+
): Promise<AICallRecord | null> {
|
|
88
|
+
if (!projectId) throw new Error("AIStore.getAICall: projectId is required");
|
|
89
|
+
const row = await db
|
|
90
|
+
.prepare(
|
|
91
|
+
`SELECT * FROM ai_calls WHERE project_id = ? AND call_id = ? LIMIT 1`,
|
|
92
|
+
)
|
|
93
|
+
.bind(projectId, callId)
|
|
94
|
+
.first<AICallRow>();
|
|
95
|
+
return row ? mapAICallRow(row) : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
56
98
|
export async function getAICallsOverview(
|
|
57
99
|
db: SqlDb,
|
|
58
100
|
options: AICallsOverviewOptions,
|
|
@@ -109,29 +151,7 @@ export async function getAICallsOverview(
|
|
|
109
151
|
.bind(...summaryParams)
|
|
110
152
|
.first<AICallSummaryRow>()) || {};
|
|
111
153
|
|
|
112
|
-
const calls: AICallRecord[] = (results.results || []).map(
|
|
113
|
-
projectId: row.project_id ?? "default",
|
|
114
|
-
callId: row.call_id,
|
|
115
|
-
traceId: row.trace_id,
|
|
116
|
-
spanId: row.span_id,
|
|
117
|
-
serviceName: row.service_name,
|
|
118
|
-
modelName: row.model_name,
|
|
119
|
-
provider: row.provider,
|
|
120
|
-
callType: row.call_type,
|
|
121
|
-
requestJson: row.request_json,
|
|
122
|
-
responseJson: row.response_json,
|
|
123
|
-
promptTokens: row.prompt_tokens,
|
|
124
|
-
completionTokens: row.completion_tokens,
|
|
125
|
-
totalCostUsd: row.total_cost_usd,
|
|
126
|
-
latencyMs: row.latency_ms,
|
|
127
|
-
isError: row.is_error === 1 || row.is_error === true,
|
|
128
|
-
errorMessage: row.error_message,
|
|
129
|
-
occurredAt: row.occurred_at,
|
|
130
|
-
receivedAt: row.received_at,
|
|
131
|
-
expiresAt: row.expires_at,
|
|
132
|
-
sessionId: row.session_id ?? null,
|
|
133
|
-
interactionId: row.interaction_id ?? null,
|
|
134
|
-
}));
|
|
154
|
+
const calls: AICallRecord[] = (results.results || []).map(mapAICallRow);
|
|
135
155
|
|
|
136
156
|
return {
|
|
137
157
|
calls,
|
package/src/lib/ai-store.ts
CHANGED
|
@@ -10,7 +10,11 @@ import type {
|
|
|
10
10
|
AISpansOverviewOptions,
|
|
11
11
|
AISpansOverviewResponse,
|
|
12
12
|
} from "@obsunified/types";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
getAICallById,
|
|
15
|
+
getAICallsOverview,
|
|
16
|
+
ingestAICallBatch,
|
|
17
|
+
} from "./ai-store/calls";
|
|
14
18
|
import {
|
|
15
19
|
getAIEvaluation,
|
|
16
20
|
ingestAIEvaluations,
|
|
@@ -37,6 +41,13 @@ export class AIStore {
|
|
|
37
41
|
return getAICallsOverview(this.db, options);
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
async getAICall(
|
|
45
|
+
projectId: string,
|
|
46
|
+
callId: string,
|
|
47
|
+
): Promise<AICallRecord | null> {
|
|
48
|
+
return getAICallById(this.db, projectId, callId);
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
async purgeExpired(): Promise<number> {
|
|
41
52
|
return purgeExpiredAIData(this.db);
|
|
42
53
|
}
|
|
@@ -36,7 +36,11 @@ import {
|
|
|
36
36
|
type Logger,
|
|
37
37
|
} from "../framework/logger";
|
|
38
38
|
import { AnalysesStore } from "./analyses-store";
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
evaluateRemediationWatchdogs,
|
|
41
|
+
openOrDeduplicateIncident,
|
|
42
|
+
resolveServiceIncidentTarget,
|
|
43
|
+
} from "./autosre-remediation-gate";
|
|
40
44
|
import { generateNarrative, LlmCallError, type LlmConfig } from "./llm";
|
|
41
45
|
import {
|
|
42
46
|
computeSignature,
|
|
@@ -292,6 +296,20 @@ export async function runAllDueAnalyses(
|
|
|
292
296
|
totals.narrated += summary.narrated;
|
|
293
297
|
totals.skipped += summary.skipped ?? 0;
|
|
294
298
|
}
|
|
299
|
+
|
|
300
|
+
// RFC 0013 § 5.B: the post-remediation watchdog (grant/claim timeouts and the
|
|
301
|
+
// +2m/+5m/+15m recovery checkpoints) rides the 1-minute tick so checkpoints
|
|
302
|
+
// are evaluated close to when they come due. Cross-project by design.
|
|
303
|
+
try {
|
|
304
|
+
const wd = await evaluateRemediationWatchdogs(db, { logger });
|
|
305
|
+
if (wd.evaluated + wd.timedOutClaims + wd.expiredGrants + wd.failed > 0) {
|
|
306
|
+
logger.info("[analyses] remediation watchdog summary", { ...wd });
|
|
307
|
+
}
|
|
308
|
+
} catch (error) {
|
|
309
|
+
logger.error("[analyses] remediation watchdog failed", {
|
|
310
|
+
error: error instanceof Error ? error.message : String(error),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
295
313
|
return totals;
|
|
296
314
|
}
|
|
297
315
|
|
|
@@ -477,12 +495,31 @@ async function runDueAnalysesForProject(
|
|
|
477
495
|
}
|
|
478
496
|
await store.insertResult(result, expiresAt);
|
|
479
497
|
if (result.status === "critical") {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
498
|
+
try {
|
|
499
|
+
const scopeService = def.scope?.service;
|
|
500
|
+
const target = await resolveServiceIncidentTarget(
|
|
501
|
+
db,
|
|
502
|
+
projectId,
|
|
503
|
+
typeof scopeService === "string" ? scopeService : null,
|
|
504
|
+
`analysis:${def.id}`,
|
|
505
|
+
logger,
|
|
506
|
+
);
|
|
507
|
+
await openOrDeduplicateIncident(db, projectId, {
|
|
508
|
+
environment: target.environment,
|
|
509
|
+
targetNodeId: target.targetNodeId,
|
|
510
|
+
symptomAnchorKind: "analysis",
|
|
511
|
+
symptomAnchorId: def.id,
|
|
512
|
+
});
|
|
513
|
+
} catch (incidentError) {
|
|
514
|
+
// Incident bookkeeping must not fail the analysis run.
|
|
515
|
+
logger.error("[analyses] failed to open incident", {
|
|
516
|
+
analysis_id: def.id,
|
|
517
|
+
error:
|
|
518
|
+
incidentError instanceof Error
|
|
519
|
+
? incidentError.message
|
|
520
|
+
: String(incidentError),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
486
523
|
}
|
|
487
524
|
// markRan releases the claim as a side-effect.
|
|
488
525
|
await store.markRan(projectId, def.id, result.generatedAt);
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
RelationshipCertainty,
|
|
13
13
|
} from "@obsunified/types";
|
|
14
14
|
import { buildCanonicalNodeId } from "@obsunified/types";
|
|
15
|
+
import { normalizeEnvironment } from "@obsunified/types/lineage";
|
|
15
16
|
import { randomHex } from "./hash";
|
|
16
17
|
import { parseJsonArray, parseJsonRecord } from "./json";
|
|
17
18
|
import type { SqlDb } from "./sql-db";
|
|
@@ -73,14 +74,30 @@ export const mapChangeEventRow = (row: ChangeEventRow): ChangeEvent => ({
|
|
|
73
74
|
expiresAt: row.expires_at,
|
|
74
75
|
});
|
|
75
76
|
|
|
77
|
+
export class InvalidTimestampError extends Error {
|
|
78
|
+
constructor(field: string, value: string) {
|
|
79
|
+
super(`${field} must be a valid ISO-8601 timestamp, got "${value}"`);
|
|
80
|
+
this.name = "InvalidTimestampError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Canonicalizes a client timestamp to `toISOString()` form so string comparison in SQL is sound. */
|
|
85
|
+
const normalizeTimestamp = (field: string, value: string): string => {
|
|
86
|
+
const ms = new Date(value).getTime();
|
|
87
|
+
if (!Number.isFinite(ms)) throw new InvalidTimestampError(field, value);
|
|
88
|
+
return new Date(ms).toISOString();
|
|
89
|
+
};
|
|
90
|
+
|
|
76
91
|
export async function recordChangeEvent(
|
|
77
92
|
db: SqlDb,
|
|
78
93
|
projectId: string,
|
|
79
94
|
input: ChangeEventInput,
|
|
80
95
|
): Promise<ChangeEvent> {
|
|
81
96
|
const nowIso = new Date().toISOString();
|
|
82
|
-
const occurredAt = input.occurredAt
|
|
83
|
-
|
|
97
|
+
const occurredAt = input.occurredAt
|
|
98
|
+
? normalizeTimestamp("occurredAt", input.occurredAt)
|
|
99
|
+
: nowIso;
|
|
100
|
+
const environment = normalizeEnvironment(input.environment);
|
|
84
101
|
const namespace = (input.namespace || "default").trim();
|
|
85
102
|
const serviceName = input.serviceName?.trim() || null;
|
|
86
103
|
|
|
@@ -257,7 +274,7 @@ export async function listChangeEvents(
|
|
|
257
274
|
}
|
|
258
275
|
if (options.since) {
|
|
259
276
|
conditions.push("occurred_at >= ?");
|
|
260
|
-
binds.push(options.since);
|
|
277
|
+
binds.push(normalizeTimestamp("since", options.since));
|
|
261
278
|
}
|
|
262
279
|
|
|
263
280
|
const limit = Math.max(1, Math.min(200, options.limit ?? 50));
|
|
@@ -346,8 +363,10 @@ const summarizeCohort = (
|
|
|
346
363
|
requestCount > 0 ? downstreamCount / requestCount : 0;
|
|
347
364
|
const avgDownstreamDurationMs =
|
|
348
365
|
downstreamCount > 0
|
|
349
|
-
? downstreamSpans.reduce(
|
|
350
|
-
|
|
366
|
+
? downstreamSpans.reduce(
|
|
367
|
+
(acc, s) => acc + Number(s.duration_ms ?? 0),
|
|
368
|
+
0,
|
|
369
|
+
) / downstreamCount
|
|
351
370
|
: null;
|
|
352
371
|
|
|
353
372
|
return {
|
|
@@ -433,7 +452,9 @@ export async function findCrossTraceContention(
|
|
|
433
452
|
}>(),
|
|
434
453
|
]);
|
|
435
454
|
|
|
436
|
-
const isScoped = nodeRow
|
|
455
|
+
const isScoped = nodeRow
|
|
456
|
+
? nodeRow.is_scoped_identity === 1
|
|
457
|
+
: !options.targetNodeId.includes(":unscoped/");
|
|
437
458
|
const relationshipCertainty: RelationshipCertainty = isScoped
|
|
438
459
|
? "exact"
|
|
439
460
|
: "inferred";
|
|
@@ -455,7 +476,11 @@ export async function findCrossTraceContention(
|
|
|
455
476
|
const extractFilePathsFromSpan = (span: SpanCohortSampleRow): string[] => {
|
|
456
477
|
const found: string[] = [];
|
|
457
478
|
const attrs = parseJsonRecord(span.attributes_json ?? "{}");
|
|
458
|
-
for (const key of [
|
|
479
|
+
for (const key of [
|
|
480
|
+
"code.filepath",
|
|
481
|
+
"obs.code.filepath",
|
|
482
|
+
"exception.stacktrace",
|
|
483
|
+
]) {
|
|
459
484
|
const val = attrs[key];
|
|
460
485
|
if (typeof val === "string") {
|
|
461
486
|
found.push(val);
|
|
@@ -556,9 +581,9 @@ export async function computeChangeBlastRadius(
|
|
|
556
581
|
|
|
557
582
|
const flagKey =
|
|
558
583
|
change.kind === "feature_flag"
|
|
559
|
-
? (typeof change.attributes.flagKey === "string"
|
|
584
|
+
? ((typeof change.attributes.flagKey === "string"
|
|
560
585
|
? change.attributes.flagKey
|
|
561
|
-
: change.serviceName) ?? "flag"
|
|
586
|
+
: change.serviceName) ?? "flag")
|
|
562
587
|
: null;
|
|
563
588
|
|
|
564
589
|
const newCohortKey =
|
|
@@ -577,7 +602,10 @@ export async function computeChangeBlastRadius(
|
|
|
577
602
|
const attrs = parseJsonRecord(s.attributes_json ?? "{}");
|
|
578
603
|
const direct = attrs[`obs.feature_flags.${flagKey}`];
|
|
579
604
|
if (direct !== undefined && direct !== null) return String(direct);
|
|
580
|
-
if (
|
|
605
|
+
if (
|
|
606
|
+
attrs["feature_flag.key"] === flagKey &&
|
|
607
|
+
attrs["feature_flag.variant"] !== undefined
|
|
608
|
+
) {
|
|
581
609
|
return String(attrs["feature_flag.variant"]);
|
|
582
610
|
}
|
|
583
611
|
const nested = attrs["obs.feature_flags"];
|
|
@@ -732,8 +760,7 @@ export async function computeChangeBlastRadius(
|
|
|
732
760
|
} else if (preBaseline.requestCount >= minSample) {
|
|
733
761
|
// Instant 100% cutover: compare post-change v_new vs pre-change baseline
|
|
734
762
|
rateDifference = newCohort.errorRate - preBaseline.errorRate;
|
|
735
|
-
relativeRisk =
|
|
736
|
-
newCohort.errorRate / Math.max(preBaseline.errorRate, 0.001);
|
|
763
|
+
relativeRisk = newCohort.errorRate / Math.max(preBaseline.errorRate, 0.001);
|
|
737
764
|
const p95Ratio =
|
|
738
765
|
newCohort.p95DurationMs !== null &&
|
|
739
766
|
preBaseline.p95DurationMs !== null &&
|