feat(relay): enrich request lifecycle telemetry for retries and outcomes

This commit is contained in:
2026-03-24 12:54:07 +00:00
parent 0faf79945f
commit 1ba0cbe8a9
3 changed files with 205 additions and 5 deletions
+41
View File
@@ -1222,3 +1222,44 @@ Validation:
Follow-ups: Follow-ups:
- Optional next iteration: add a PR template block in Azure DevOps mirroring the runbook governance gate checklist. - Optional next iteration: add a PR template block in Azure DevOps mirroring the runbook governance gate checklist.
---
### CL-033: TASK22242 relay telemetry enrichment (lifecycle events + correlation fields)
date: 2026-03-24
author: Cline
scope: `pages/api/middleware/relayForwarding.js`, `tests/phase21/relay-forwarding-hardening.test.cjs`
type: change
rationale: Add richer relay observability so operations can correlate retries and outcomes per request and track latency/status patterns without changing endpoint contracts.
impact: Improves operational diagnostics and trend analysis for relay traffic while preserving existing API behavior.
status: completed
Summary:
- Enriched relay middleware telemetry with request lifecycle events:
- `relay_request_started`
- `relay_request_retrying`
- `relay_request_succeeded`
- `relay_request_failed`
- Added shared telemetry fields for correlation and analysis:
- `relayRequestId` (per request correlation id)
- `attemptsMade`, `retryCountUsed`, `remainingRetries`
- `elapsedMs`
- `statusClass` (`2xx/4xx/5xx` style buckets)
- resolved runtime knobs included at start event
- Kept existing retry policy and endpoint response contracts unchanged.
- Expanded phase21 relay hardening tests to assert telemetry behavior:
- started/retrying/succeeded event presence
- failed event telemetry fields
- stable `relayRequestId` across lifecycle events for one request
Validation:
- `node tests/phase21/relay-forwarding-hardening.test.cjs` -> pass (7/7)
- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (152/152)
- `npm run lint` -> warnings only (pre-existing `react-hooks/exhaustive-deps`; no new lint errors)
Follow-ups:
- Optional next step: map these lifecycle fields into central dashboards/alerts (retry rate, status-class distribution, p95 elapsedMs).
+51 -3
View File
@@ -109,6 +109,19 @@ const structuredRelayLog = (event, payload) => {
console.info(event, redactSensitive(payload)); console.info(event, redactSensitive(payload));
}; };
const buildRelayRequestId = () => {
return `relay_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
};
const getElapsedMs = (startedAtMs) => {
return Math.max(0, Date.now() - startedAtMs);
};
const getStatusClass = (status) => {
if (typeof status !== "number") return "none";
return `${Math.floor(status / 100)}xx`;
};
const shouldRetryRelayError = ({ error, attempt, maxRetries }) => { const shouldRetryRelayError = ({ error, attempt, maxRetries }) => {
if (attempt >= maxRetries) return false; if (attempt >= maxRetries) return false;
@@ -226,11 +239,35 @@ export const forwardGetData = async ({
: resolvedTimeoutMs : resolvedTimeoutMs
}; };
const relayRequestId = buildRelayRequestId();
const startedAtMs = Date.now();
structuredRelayLog("relay_request_started", {
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
timeoutMs: resolvedTimeoutMs,
maxRetries: resolvedMaxRetries,
retryBaseDelayMs: resolvedRetryBaseDelayMs,
retryMaxDelayMs: resolvedRetryMaxDelayMs
});
let lastError; let lastError;
for (let attempt = 0; attempt <= resolvedMaxRetries; attempt += 1) { for (let attempt = 0; attempt <= resolvedMaxRetries; attempt += 1) {
try { try {
const { data } = await axios.get(finalUrl, axiosOptions); const { data, status } = await axios.get(finalUrl, axiosOptions);
structuredRelayLog("relay_request_succeeded", {
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
status,
statusClass: getStatusClass(status),
attemptsMade: attempt + 1,
retryCountUsed: attempt,
elapsedMs: getElapsedMs(startedAtMs)
});
return { return {
data, data,
@@ -248,13 +285,18 @@ export const forwardGetData = async ({
if (!retryEligible) { if (!retryEligible) {
error.__relayAlreadyLogged = true; error.__relayAlreadyLogged = true;
structuredRelayLog("relay_request_failed", { structuredRelayLog("relay_request_failed", {
relayRequestId,
queryUrl, queryUrl,
baseUrl: resolvedBaseUrl, baseUrl: resolvedBaseUrl,
attempt: attempt + 1, attempt: attempt + 1,
maxAttempts: resolvedMaxRetries + 1, maxAttempts: resolvedMaxRetries + 1,
attemptsMade: attempt + 1,
retryCountUsed: attempt,
status: error?.response?.status, status: error?.response?.status,
statusClass: getStatusClass(error?.response?.status),
code: error?.code, code: error?.code,
message: error?.message message: error?.message,
elapsedMs: getElapsedMs(startedAtMs)
}); });
throw error; throw error;
} }
@@ -266,14 +308,20 @@ export const forwardGetData = async ({
}); });
structuredRelayLog("relay_request_retrying", { structuredRelayLog("relay_request_retrying", {
relayRequestId,
queryUrl, queryUrl,
baseUrl: resolvedBaseUrl, baseUrl: resolvedBaseUrl,
attempt: attempt + 1, attempt: attempt + 1,
maxAttempts: resolvedMaxRetries + 1, maxAttempts: resolvedMaxRetries + 1,
attemptsMade: attempt + 1,
retryCountUsed: attempt,
remainingRetries: resolvedMaxRetries - attempt,
delayMs, delayMs,
status: error?.response?.status, status: error?.response?.status,
statusClass: getStatusClass(error?.response?.status),
code: error?.code, code: error?.code,
message: error?.message message: error?.message,
elapsedMs: getElapsedMs(startedAtMs)
}); });
await wait(delayMs); await wait(delayMs);
@@ -43,6 +43,7 @@ const test = (name, fn) => tests.push({ name, fn });
test("forwardGetData retries retryable HTTP status and then succeeds", async () => { test("forwardGetData retries retryable HTTP status and then succeeds", async () => {
let callCount = 0; let callCount = 0;
const infoLogs = [];
const mod = loadRelayForwardingModule({ const mod = loadRelayForwardingModule({
axios: { axios: {
@@ -61,7 +62,13 @@ test("forwardGetData retries retryable HTTP status and then succeeds", async ()
hashAPIPath: () => "&hash=abc", hashAPIPath: () => "&hash=abc",
azureHeaders: () => ({ headers: { Authorization: "Bearer token" } }), azureHeaders: () => ({ headers: { Authorization: "Bearer token" } }),
redactSensitive: (value) => value, redactSensitive: (value) => value,
consoleLogger: () => {} consoleLogger: () => {},
console: {
log: () => {},
info: (...args) => infoLogs.push(args),
warn: () => {},
error: () => {}
}
}); });
const result = await mod.forwardGetData({ const result = await mod.forwardGetData({
@@ -74,10 +81,39 @@ test("forwardGetData retries retryable HTTP status and then succeeds", async ()
assert.strictEqual(callCount, 2); assert.strictEqual(callCount, 2);
assert.deepStrictEqual(result.data, { ok: true }); assert.deepStrictEqual(result.data, { ok: true });
assert.strictEqual(result.accessToken, "token"); assert.strictEqual(result.accessToken, "token");
const eventNames = infoLogs.map((entry) => entry[0]);
assert.ok(eventNames.includes("relay_request_started"));
assert.ok(eventNames.includes("relay_request_retrying"));
assert.ok(eventNames.includes("relay_request_succeeded"));
const retryEvent = infoLogs.find(
(entry) => entry[0] === "relay_request_retrying"
);
assert.ok(retryEvent);
const retryPayload = retryEvent[1];
assert.strictEqual(retryPayload.statusClass, "5xx");
assert.strictEqual(retryPayload.attemptsMade, 1);
assert.strictEqual(retryPayload.retryCountUsed, 0);
assert.strictEqual(retryPayload.remainingRetries, 1);
assert.ok(typeof retryPayload.relayRequestId === "string");
const successEvent = infoLogs.find(
(entry) => entry[0] === "relay_request_succeeded"
);
assert.ok(successEvent);
const successPayload = successEvent[1];
assert.strictEqual(successPayload.statusClass, "none");
assert.strictEqual(successPayload.attemptsMade, 2);
assert.strictEqual(successPayload.retryCountUsed, 1);
assert.ok(typeof successPayload.elapsedMs === "number");
}); });
test("forwardGetData does not retry non-retryable HTTP status", async () => { test("forwardGetData does not retry non-retryable HTTP status", async () => {
let callCount = 0; let callCount = 0;
const infoLogs = [];
const mod = loadRelayForwardingModule({ const mod = loadRelayForwardingModule({
axios: { axios: {
@@ -92,7 +128,13 @@ test("forwardGetData does not retry non-retryable HTTP status", async () => {
hashAPIPath: () => "&hash=abc", hashAPIPath: () => "&hash=abc",
azureHeaders: () => ({ headers: {} }), azureHeaders: () => ({ headers: {} }),
redactSensitive: (value) => value, redactSensitive: (value) => value,
consoleLogger: () => {} consoleLogger: () => {},
console: {
log: () => {},
info: (...args) => infoLogs.push(args),
warn: () => {},
error: () => {}
}
}); });
let thrown = null; let thrown = null;
@@ -109,6 +151,17 @@ test("forwardGetData does not retry non-retryable HTTP status", async () => {
assert.ok(thrown); assert.ok(thrown);
assert.strictEqual(callCount, 1); assert.strictEqual(callCount, 1);
const failedEvent = infoLogs.find(
(entry) => entry[0] === "relay_request_failed"
);
assert.ok(failedEvent);
const failedPayload = failedEvent[1];
assert.strictEqual(failedPayload.statusClass, "4xx");
assert.strictEqual(failedPayload.attemptsMade, 1);
assert.strictEqual(failedPayload.retryCountUsed, 0);
assert.ok(typeof failedPayload.elapsedMs === "number");
}); });
test("forwardGetData does not retry unauthorized status", async () => { test("forwardGetData does not retry unauthorized status", async () => {
@@ -258,6 +311,64 @@ test("forwardGetData applies timeout and appendHash=false behavior", async () =>
assert.strictEqual(capturedOptions.headers.Accept, "application/json"); assert.strictEqual(capturedOptions.headers.Accept, "application/json");
}); });
test("forwardGetData telemetry request id is stable across lifecycle events", async () => {
const infoLogs = [];
let callCount = 0;
const mod = loadRelayForwardingModule({
axios: {
get: async () => {
callCount += 1;
if (callCount === 1) {
const error = new Error("service unavailable");
error.response = { status: 503 };
throw error;
}
return { data: { ok: true }, status: 200 };
}
},
getToken: async () => ({ access_token: "token" }),
hashAPIPath: () => "&hash=abc",
azureHeaders: () => ({ headers: {} }),
redactSensitive: (value) => value,
consoleLogger: () => {},
console: {
log: () => {},
info: (...args) => infoLogs.push(args),
warn: () => {},
error: () => {}
}
});
await mod.forwardGetData({
queryUrl: "incidents?$top=1",
maxRetries: 1,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0
});
const lifecycleEvents = infoLogs
.filter((entry) =>
[
"relay_request_started",
"relay_request_retrying",
"relay_request_succeeded"
].includes(entry[0])
)
.map((entry) => entry[1]);
assert.strictEqual(lifecycleEvents.length, 3);
const relayRequestIds = lifecycleEvents.map(
(event) => event.relayRequestId
);
assert.ok(
relayRequestIds.every((id) => typeof id === "string" && id.length > 0)
);
assert.strictEqual(new Set(relayRequestIds).size, 1);
});
const run = async () => { const run = async () => {
let passed = 0; let passed = 0;
for (const currentTest of tests) { for (const currentTest of tests) {