fb1fe8d94a
问题:hwlab-cloud-api 服务端 503(如 workbench 投影存储不可用)在 Tempo 里只有 workbench-ui 浏览器侧 span,没有服务端 request handler span,导致 后端根因(error.code=projection_store_unavailable 等)只能在应用日志里看, OTel 链路追不到服务端处理层。 修复: - otel-trace.ts 新增 emitHttpServerRequestSpan(scope hwlab.http.server, SPAN_KIND_SERVER),捕获 http.route/method/status_code/status_class、 request.id,5xx 时落 STATUS_CODE_ERROR 并把 error.code/category/layer 写入 span 属性与 status.message。 - error-envelope.ts 的 decorateErrorPayloadForHttp 把诊断 error code/ category/layer 回写到 hwlabHttpRequestContext.lastHttpError,供 finish 时复用,避免重复解析。 - server.ts 在 response finish 时按 http context + backend performance 时序发服务端 span;health/metrics 探针路由跳过,避免 OTel 噪声洪泛。 验收:canary 调用 emitHttpServerRequestSpan 发 503 projection_store_unavailable span 到 D601 platform-infra OTel collector,Tempo 可查到 service=hwlab-cloud-api、 scope=hwlab.http.server、http.response.status_code=503、error.code= projection_store_unavailable 的 ERROR span。
146 lines
5.7 KiB
TypeScript
146 lines
5.7 KiB
TypeScript
export const HWLAB_ERROR_DIAGNOSTIC_VERSION = "hwlab-error-diagnostic-v1";
|
|
|
|
export function decorateErrorPayloadForHttp(payload, options = {}) {
|
|
const statusCode = numericStatus(options.statusCode);
|
|
if (!statusCode || statusCode < 400 || !plainObject(payload) || !("error" in payload)) return payload;
|
|
|
|
const context = options.context ?? options.response?.hwlabHttpRequestContext ?? options.request?.hwlabHttpRequestContext ?? {};
|
|
const originalError = normalizeErrorObject(payload.error, payload);
|
|
if (!originalError) return payload;
|
|
|
|
const rawCode = originalError.code ?? payload.code ?? payload.error ?? statusCodeToCode(statusCode);
|
|
const diagnosticCode = safeCode(rawCode);
|
|
const errorCode = typeof originalError.code === "number" ? originalError.code : diagnosticCode;
|
|
const message = firstText(originalError.message, payload.message, diagnosticCode);
|
|
const userMessage = firstText(originalError.userMessage, payload.userMessage, message);
|
|
const diagnostic = {
|
|
contractVersion: HWLAB_ERROR_DIAGNOSTIC_VERSION,
|
|
traceId: safeOtelTraceId(context.traceId) ?? safeOtelTraceId(originalError.diagnostic?.traceId) ?? safeOtelTraceId(originalError.otelTraceId) ?? safeOtelTraceId(payload.otelTraceId) ?? null,
|
|
requestId: safeRequestId(context.requestId) ?? safeRequestId(originalError.diagnostic?.requestId) ?? safeRequestId(originalError.requestId) ?? safeRequestId(payload.requestId) ?? null,
|
|
serviceId: safeToken(originalError.serviceId ?? payload.serviceId ?? options.serviceId, "hwlab-cloud-api"),
|
|
route: safeRoute(originalError.route ?? payload.route ?? options.route ?? context.route),
|
|
layer: safeToken(originalError.layer ?? options.layer, "api"),
|
|
category: safeToken(originalError.category ?? options.category, categoryForStatus(statusCode)),
|
|
code: diagnosticCode,
|
|
httpStatus: statusCode,
|
|
source: safeToken(options.source, "server"),
|
|
observedAt: new Date().toISOString(),
|
|
valuesPrinted: false
|
|
};
|
|
const nextError = {
|
|
...originalError,
|
|
code: errorCode,
|
|
message,
|
|
userMessage,
|
|
diagnostic: {
|
|
...(plainObject(originalError.diagnostic) ? originalError.diagnostic : {}),
|
|
...diagnostic
|
|
},
|
|
valuesPrinted: originalError.valuesPrinted === true ? true : false
|
|
};
|
|
const httpContext = options.context ?? options.response?.hwlabHttpRequestContext ?? options.request?.hwlabHttpRequestContext;
|
|
if (plainObject(httpContext)) {
|
|
httpContext.lastHttpError = { code: diagnosticCode, category: diagnostic.category, layer: diagnostic.layer, httpStatus: statusCode, valuesPrinted: false };
|
|
}
|
|
return { ...payload, error: nextError };
|
|
}
|
|
|
|
export function logErrorEnvelope(payload, options = {}) {
|
|
const statusCode = numericStatus(options.statusCode);
|
|
if (!statusCode || statusCode < 400 || !plainObject(payload) || !plainObject(payload.error)) return;
|
|
const diagnostic = plainObject(payload.error.diagnostic) ? payload.error.diagnostic : null;
|
|
if (!diagnostic) return;
|
|
const record = {
|
|
event: "hwlab.error",
|
|
statusCode,
|
|
trace_id: diagnostic.traceId ?? null,
|
|
request_id: diagnostic.requestId ?? null,
|
|
route: diagnostic.route ?? null,
|
|
serviceId: diagnostic.serviceId ?? "hwlab-cloud-api",
|
|
error: {
|
|
code: payload.error.code ?? diagnostic.code ?? "unknown_error",
|
|
layer: payload.error.layer ?? diagnostic.layer ?? "api",
|
|
category: payload.error.category ?? diagnostic.category ?? categoryForStatus(statusCode),
|
|
retryable: payload.error.retryable ?? null
|
|
},
|
|
valuesPrinted: false
|
|
};
|
|
const logger = options.logger ?? console;
|
|
const line = JSON.stringify(record);
|
|
if (statusCode >= 500 && typeof logger.error === "function") {
|
|
logger.error(line);
|
|
} else if (typeof logger.warn === "function") {
|
|
logger.warn(line);
|
|
}
|
|
}
|
|
|
|
function normalizeErrorObject(value, payload) {
|
|
if (plainObject(value)) return { ...value };
|
|
const text = firstText(value, payload?.message, "unknown_error");
|
|
return {
|
|
code: safeCode(text),
|
|
message: text,
|
|
userMessage: text
|
|
};
|
|
}
|
|
|
|
function numericStatus(value) {
|
|
const status = Number(value);
|
|
return Number.isInteger(status) && status >= 100 && status <= 599 ? status : null;
|
|
}
|
|
|
|
function statusCodeToCode(statusCode) {
|
|
if (statusCode === 400) return "bad_request";
|
|
if (statusCode === 401) return "auth_required";
|
|
if (statusCode === 403) return "forbidden";
|
|
if (statusCode === 404) return "not_found";
|
|
if (statusCode === 409) return "conflict";
|
|
if (statusCode === 429) return "rate_limited";
|
|
if (statusCode >= 500) return "internal_error";
|
|
return "error";
|
|
}
|
|
|
|
function categoryForStatus(statusCode) {
|
|
if (statusCode >= 500) return "server";
|
|
if (statusCode >= 400) return "client";
|
|
return "error";
|
|
}
|
|
|
|
function firstText(...values) {
|
|
for (const value of values) {
|
|
const text = String(value ?? "").trim();
|
|
if (text) return text;
|
|
}
|
|
return "unknown_error";
|
|
}
|
|
|
|
function safeCode(value) {
|
|
const text = firstText(value).toLowerCase().replace(/[^a-z0-9_.:-]+/gu, "_").replace(/^_+|_+$/gu, "");
|
|
return text.slice(0, 96) || "unknown_error";
|
|
}
|
|
|
|
function safeToken(value, fallback) {
|
|
const text = String(value ?? "").trim();
|
|
return /^[A-Za-z0-9_.:-]{1,96}$/u.test(text) ? text : fallback;
|
|
}
|
|
|
|
function safeRoute(value) {
|
|
const text = String(value ?? "").trim().split("?")[0] || "/";
|
|
if (!text.startsWith("/")) return "/";
|
|
return text.slice(0, 180);
|
|
}
|
|
|
|
function safeRequestId(value) {
|
|
const text = String(value ?? "").trim();
|
|
return /^[A-Za-z0-9_.:-]{3,180}$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function safeOtelTraceId(value) {
|
|
const text = String(value ?? "").trim().toLowerCase();
|
|
return /^[0-9a-f]{32}$/u.test(text) && text !== "00000000000000000000000000000000" ? text : null;
|
|
}
|
|
|
|
function plainObject(value) {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|