396 lines
16 KiB
TypeScript
396 lines
16 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
|
|
const OTLP_TIMEOUT_MS = 1500;
|
|
const MIN_VALID_EPOCH_MS = Date.UTC(2020, 0, 1);
|
|
const ZERO_TRACE_ID = "00000000000000000000000000000000";
|
|
const ZERO_SPAN_ID = "0000000000000000";
|
|
|
|
export function codeAgentOtelTraceFields(traceId, env = process.env) {
|
|
const context = codeAgentOtelTraceContext(traceId);
|
|
return {
|
|
otelTraceId: context.traceId,
|
|
traceparent: context.traceparent,
|
|
otelTrace: {
|
|
traceId: context.traceId,
|
|
businessTraceId: context.businessTraceId,
|
|
tempoPath: `/api/traces/${context.traceId}`,
|
|
exporterConfigured: Boolean(resolveOtlpTracesEndpoint(env)),
|
|
valuesPrinted: false
|
|
}
|
|
};
|
|
}
|
|
|
|
export function codeAgentOtelTraceContext(traceId) {
|
|
const businessTraceId = String(traceId ?? "").trim() || "trc_unassigned";
|
|
const otelTraceId = nonZeroHex(createHash("sha256").update(`hwlab-code-agent:${businessTraceId}`).digest("hex").slice(0, 32), ZERO_TRACE_ID);
|
|
const parentSpanId = nonZeroHex(createHash("sha256").update(`hwlab-code-agent-parent:${businessTraceId}`).digest("hex").slice(0, 16), ZERO_SPAN_ID);
|
|
return {
|
|
businessTraceId,
|
|
traceId: otelTraceId,
|
|
parentSpanId,
|
|
traceparent: `00-${otelTraceId}-${parentSpanId}-01`,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
export function workbenchUiOtelTraceContext(input = {}) {
|
|
const businessTraceId = String(input.uiTraceId ?? input.traceId ?? "").trim() || "ui_unassigned";
|
|
const explicitTraceId = normalizeOtelTraceId(input.otelTraceId);
|
|
const otelTraceId = explicitTraceId ?? nonZeroHex(createHash("sha256").update(`hwlab-workbench-ui:${businessTraceId}`).digest("hex").slice(0, 32), ZERO_TRACE_ID);
|
|
const parentSpanId = normalizeOtelSpanId(input.parentSpanId)
|
|
?? nonZeroHex(createHash("sha256").update(`hwlab-workbench-ui-parent:${businessTraceId}:${otelTraceId}`).digest("hex").slice(0, 16), ZERO_SPAN_ID);
|
|
return {
|
|
businessTraceId,
|
|
traceId: otelTraceId,
|
|
parentSpanId,
|
|
traceparent: `00-${otelTraceId}-${parentSpanId}-01`,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
export function authLoginOtelTraceContext(input = {}) {
|
|
const parsed = parseOtelTraceparent(input.traceparent);
|
|
const traceId = parsed?.traceId ?? nonZeroHex(randomBytes(16).toString("hex"), ZERO_TRACE_ID);
|
|
const rootSpanId = normalizeOtelSpanId(input.rootSpanId) ?? newOtelSpanId();
|
|
return {
|
|
traceId,
|
|
parentSpanId: parsed?.spanId ?? null,
|
|
rootSpanId,
|
|
traceparent: `00-${traceId}-${rootSpanId}-01`,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
export function httpRequestOtelTraceContext(input = {}) {
|
|
const parsed = parseOtelTraceparent(input.traceparent);
|
|
const traceId = parsed?.traceId
|
|
?? normalizeOtelTraceId(input.traceId)
|
|
?? nonZeroHex(randomBytes(16).toString("hex"), ZERO_TRACE_ID);
|
|
const spanId = normalizeOtelSpanId(input.spanId) ?? newOtelSpanId();
|
|
return {
|
|
traceId,
|
|
parentSpanId: parsed?.spanId ?? null,
|
|
spanId,
|
|
traceparent: `00-${traceId}-${spanId}-01`,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
export function newOtelSpanId() {
|
|
return nonZeroHex(randomBytes(8).toString("hex"), ZERO_SPAN_ID);
|
|
}
|
|
|
|
export function traceparentForOtelSpan(context, spanId) {
|
|
const traceId = normalizeOtelTraceId(context?.traceId);
|
|
const normalizedSpanId = normalizeOtelSpanId(spanId);
|
|
return traceId && normalizedSpanId ? `00-${traceId}-${normalizedSpanId}-01` : "";
|
|
}
|
|
|
|
export async function emitCodeAgentOtelSpan(name, traceId, env = process.env, options = {}) {
|
|
const endpoint = resolveOtlpTracesEndpoint(env);
|
|
if (!endpoint || typeof fetch !== "function") return { ok: false, skipped: true, reason: "otlp-endpoint-missing", valuesPrinted: false };
|
|
const context = codeAgentOtelTraceContext(traceId);
|
|
const now = Date.now();
|
|
const startedAtMs = epochUnixMs(options.startTimeMs, now);
|
|
const endedAtMs = epochUnixMs(options.endTimeMs, now);
|
|
const spanId = nonZeroHex(String(options.spanId ?? randomBytes(8).toString("hex")).slice(0, 16), ZERO_SPAN_ID);
|
|
const parentSpanId = typeof options.parentSpanId === "string" && /^[0-9a-f]{16}$/u.test(options.parentSpanId) ? options.parentSpanId : context.parentSpanId;
|
|
const statusCode = options.status === "error" || options.error ? 2 : 1;
|
|
const body = {
|
|
resourceSpans: [{
|
|
resource: { attributes: attributesFromRecord(resourceAttributes(env)) },
|
|
scopeSpans: [{
|
|
scope: { name: "hwlab.code-agent", version: "1" },
|
|
spans: [{
|
|
traceId: context.traceId,
|
|
spanId,
|
|
parentSpanId,
|
|
name,
|
|
kind: Number(options.kind ?? 1),
|
|
startTimeUnixNano: unixNano(startedAtMs),
|
|
endTimeUnixNano: unixNano(Math.max(startedAtMs, endedAtMs)),
|
|
attributes: attributesFromRecord({
|
|
traceId: context.businessTraceId,
|
|
"otel.trace_id": context.traceId,
|
|
"code_agent.stage": name,
|
|
...options.attributes
|
|
}),
|
|
status: {
|
|
code: statusCode,
|
|
...(options.error ? { message: String(options.error?.message ?? options.error).slice(0, 300) } : {})
|
|
}
|
|
}]
|
|
}]
|
|
}]
|
|
};
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), OTLP_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal: controller.signal
|
|
});
|
|
return { ok: response.ok, status: response.status, valuesPrinted: false };
|
|
} catch (error) {
|
|
return { ok: false, error: error?.name === "AbortError" ? "otlp-timeout" : "otlp-send-failed", valuesPrinted: false };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
export async function emitWorkbenchUiOtelSpan(name, uiTraceId, env = process.env, options = {}) {
|
|
const endpoint = resolveOtlpTracesEndpoint(env);
|
|
if (!endpoint || typeof fetch !== "function") return { ok: false, skipped: true, reason: "otlp-endpoint-missing", valuesPrinted: false };
|
|
const context = workbenchUiOtelTraceContext({ uiTraceId, otelTraceId: options.otelTraceId, parentSpanId: options.parentSpanId });
|
|
const now = Date.now();
|
|
const startedAtMs = epochUnixMs(options.startTimeMs, now);
|
|
const endedAtMs = epochUnixMs(options.endTimeMs, startedAtMs);
|
|
const spanId = normalizeOtelSpanId(options.spanId) ?? nonZeroHex(randomBytes(8).toString("hex"), ZERO_SPAN_ID);
|
|
const parentSpanId = normalizeOtelSpanId(options.parentSpanId) ?? context.parentSpanId;
|
|
const statusCode = options.status === "error" || options.error ? 2 : 1;
|
|
const body = {
|
|
resourceSpans: [{
|
|
resource: { attributes: attributesFromRecord(resourceAttributes(env)) },
|
|
scopeSpans: [{
|
|
scope: { name: "hwlab.workbench-ui", version: "1" },
|
|
spans: [{
|
|
traceId: context.traceId,
|
|
spanId,
|
|
parentSpanId,
|
|
name,
|
|
kind: Number(options.kind ?? 1),
|
|
startTimeUnixNano: unixNano(startedAtMs),
|
|
endTimeUnixNano: unixNano(Math.max(startedAtMs, endedAtMs)),
|
|
attributes: attributesFromRecord({
|
|
"ui.trace_id": context.businessTraceId,
|
|
"otel.trace_id": context.traceId,
|
|
"workbench.ui.stage": name,
|
|
...options.attributes
|
|
}),
|
|
status: {
|
|
code: statusCode,
|
|
...(options.error ? { message: String(options.error?.message ?? options.error).slice(0, 300) } : {})
|
|
}
|
|
}]
|
|
}]
|
|
}]
|
|
};
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), OTLP_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal: controller.signal
|
|
});
|
|
return { ok: response.ok, status: response.status, traceId: context.traceId, valuesPrinted: false };
|
|
} catch (error) {
|
|
return { ok: false, error: error?.name === "AbortError" ? "otlp-timeout" : "otlp-send-failed", traceId: context.traceId, valuesPrinted: false };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
export async function emitAuthOtelSpan(name, context, env = process.env, options = {}) {
|
|
const endpoint = resolveOtlpTracesEndpoint(env);
|
|
if (!endpoint || typeof fetch !== "function") return { ok: false, skipped: true, reason: "otlp-endpoint-missing", valuesPrinted: false };
|
|
const traceContext = normalizeOtelTraceId(context?.traceId) ? context : authLoginOtelTraceContext({ traceparent: context?.traceparent });
|
|
const now = Date.now();
|
|
const startedAtMs = epochUnixMs(options.startTimeMs, now);
|
|
const endedAtMs = epochUnixMs(options.endTimeMs, startedAtMs);
|
|
const spanId = normalizeOtelSpanId(options.spanId) ?? newOtelSpanId();
|
|
const parentSpanId = normalizeOtelSpanId(options.parentSpanId) ?? null;
|
|
const statusCode = options.status === "error" || options.error ? 2 : 1;
|
|
const span = {
|
|
traceId: traceContext.traceId,
|
|
spanId,
|
|
...(parentSpanId ? { parentSpanId } : {}),
|
|
name,
|
|
kind: Number(options.kind ?? 1),
|
|
startTimeUnixNano: unixNano(startedAtMs),
|
|
endTimeUnixNano: unixNano(Math.max(startedAtMs, endedAtMs)),
|
|
attributes: attributesFromRecord({
|
|
"otel.trace_id": traceContext.traceId,
|
|
"auth.stage": name,
|
|
...options.attributes
|
|
}),
|
|
status: {
|
|
code: statusCode,
|
|
...(options.error ? { message: String(options.error?.message ?? options.error).slice(0, 300) } : {})
|
|
}
|
|
};
|
|
const body = {
|
|
resourceSpans: [{
|
|
resource: { attributes: attributesFromRecord(resourceAttributes(env)) },
|
|
scopeSpans: [{
|
|
scope: { name: "hwlab.auth", version: "1" },
|
|
spans: [span]
|
|
}]
|
|
}]
|
|
};
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), OTLP_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal: controller.signal
|
|
});
|
|
return { ok: response.ok, status: response.status, traceId: traceContext.traceId, valuesPrinted: false };
|
|
} catch (error) {
|
|
return { ok: false, error: error?.name === "AbortError" ? "otlp-timeout" : "otlp-send-failed", traceId: traceContext.traceId, valuesPrinted: false };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
export async function emitHttpServerRequestSpan(context, env = process.env, options = {}) {
|
|
const endpoint = resolveOtlpTracesEndpoint(env);
|
|
if (!endpoint || typeof fetch !== "function") return { ok: false, skipped: true, reason: "otlp-endpoint-missing", valuesPrinted: false };
|
|
const traceId = normalizeOtelTraceId(context?.traceId);
|
|
if (!traceId) return { ok: false, skipped: true, reason: "trace-id-missing", valuesPrinted: false };
|
|
const spanId = normalizeOtelSpanId(options.spanId) ?? newOtelSpanId();
|
|
const parentSpanId = normalizeOtelSpanId(options.parentSpanId) ?? normalizeOtelSpanId(context?.parentSpanId) ?? null;
|
|
const now = Date.now();
|
|
const startedAtMs = epochUnixMs(options.startTimeMs, now);
|
|
const endedAtMs = epochUnixMs(options.endTimeMs, startedAtMs);
|
|
const method = String(options.method ?? context?.method ?? "GET").toUpperCase();
|
|
const route = String(options.route ?? context?.route ?? "/").split("?")[0] || "/";
|
|
const statusCode = Number.isInteger(Number(options.statusCode)) ? Number(options.statusCode) : 0;
|
|
const statusClassValue = statusClassFor(statusCode);
|
|
const errorCode = options.errorCode ? String(options.errorCode) : null;
|
|
const isError = options.status === "error" || Boolean(options.error) || statusCode >= 500;
|
|
const name = typeof options.name === "string" && options.name.trim() ? options.name.trim() : `${method} ${route}`;
|
|
const attributes = {
|
|
"otel.trace_id": traceId,
|
|
"request.id": context?.requestId ?? null,
|
|
"http.request.method": method,
|
|
"http.route": route,
|
|
"http.response.status_code": statusCode || null,
|
|
"http.response.status_class": statusClassValue,
|
|
"hwlab.http.stage": "http.server.request",
|
|
...options.attributes,
|
|
...(errorCode ? { "error.code": errorCode, "error.category": options.errorCategory ?? null, "error.layer": options.errorLayer ?? null } : {})
|
|
};
|
|
const body = {
|
|
resourceSpans: [{
|
|
resource: { attributes: attributesFromRecord(resourceAttributes(env)) },
|
|
scopeSpans: [{
|
|
scope: { name: "hwlab.http.server", version: "1" },
|
|
spans: [{
|
|
traceId,
|
|
spanId,
|
|
...(parentSpanId ? { parentSpanId } : {}),
|
|
name,
|
|
kind: 2,
|
|
startTimeUnixNano: unixNano(startedAtMs),
|
|
endTimeUnixNano: unixNano(Math.max(startedAtMs, endedAtMs)),
|
|
attributes: attributesFromRecord(attributes),
|
|
status: {
|
|
code: isError ? 2 : 1,
|
|
...(isError && options.statusMessage ? { message: String(options.statusMessage).slice(0, 300) } : {})
|
|
}
|
|
}]
|
|
}]
|
|
}]
|
|
};
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), OTLP_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal: controller.signal
|
|
});
|
|
return { ok: response.ok, status: response.status, traceId, valuesPrinted: false };
|
|
} catch (error) {
|
|
return { ok: false, error: error?.name === "AbortError" ? "otlp-timeout" : "otlp-send-failed", traceId, valuesPrinted: false };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function statusClassFor(statusCode) {
|
|
if (statusCode >= 500) return "5xx";
|
|
if (statusCode >= 400) return "4xx";
|
|
if (statusCode >= 300) return "3xx";
|
|
if (statusCode >= 200) return "2xx";
|
|
if (statusCode >= 100) return "1xx";
|
|
return "unknown";
|
|
}
|
|
|
|
function resolveOtlpTracesEndpoint(env = process.env) {
|
|
const explicit = firstNonEmpty(env.HWLAB_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT);
|
|
if (explicit) return explicit.replace(/\/+$/u, "");
|
|
const base = firstNonEmpty(env.HWLAB_OTEL_EXPORTER_OTLP_ENDPOINT, env.OTEL_EXPORTER_OTLP_ENDPOINT);
|
|
return base ? `${base.replace(/\/+$/u, "")}/v1/traces` : null;
|
|
}
|
|
|
|
function resourceAttributes(env = process.env) {
|
|
return {
|
|
"service.name": firstNonEmpty(env.OTEL_SERVICE_NAME, "hwlab-cloud-api"),
|
|
"deployment.environment": firstNonEmpty(env.HWLAB_ENVIRONMENT, env.HWLAB_GITOPS_PROFILE, env.HWLAB_RUNTIME_LANE, "unknown"),
|
|
"unidesk.node": firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID, env.HWLAB_NODE_ID, env.UNIDESK_NODE_ID, "unknown"),
|
|
"hwlab.lane": firstNonEmpty(env.HWLAB_RUNTIME_LANE, env.HWLAB_GITOPS_PROFILE, env.HWLAB_BOOT_REF, "unknown"),
|
|
"k8s.namespace.name": firstNonEmpty(env.HWLAB_RUNTIME_NAMESPACE, env.POD_NAMESPACE, env.HWLAB_NAMESPACE, "unknown"),
|
|
"git.commit": firstNonEmpty(env.HWLAB_COMMIT_ID, env.HWLAB_GITOPS_SOURCE_COMMIT, env.HWLAB_REVISION, "unknown")
|
|
};
|
|
}
|
|
|
|
function attributesFromRecord(record = {}) {
|
|
return Object.entries(record)
|
|
.filter(([, value]) => value !== undefined && value !== null)
|
|
.map(([key, value]) => ({ key, value: otlpAnyValue(value) }));
|
|
}
|
|
|
|
function otlpAnyValue(value) {
|
|
if (typeof value === "boolean") return { boolValue: value };
|
|
if (typeof value === "number" && Number.isInteger(value)) return { intValue: String(value) };
|
|
if (typeof value === "number" && Number.isFinite(value)) return { doubleValue: value };
|
|
return { stringValue: typeof value === "string" ? value : JSON.stringify(value) };
|
|
}
|
|
|
|
function unixNano(ms) {
|
|
return String(Math.trunc(ms * 1_000_000));
|
|
}
|
|
|
|
function epochUnixMs(value, fallback) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) && numeric >= MIN_VALID_EPOCH_MS ? numeric : fallback;
|
|
}
|
|
|
|
function firstNonEmpty(...values) {
|
|
for (const value of values) {
|
|
const text = String(value ?? "").trim();
|
|
if (text) return text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function nonZeroHex(value, zero) {
|
|
return /^[0-9a-f]+$/u.test(value) && value !== zero ? value : zero.replace(/0$/u, "1");
|
|
}
|
|
|
|
function normalizeOtelTraceId(value) {
|
|
const text = String(value ?? "").trim().toLowerCase();
|
|
return /^[0-9a-f]{32}$/u.test(text) && text !== ZERO_TRACE_ID ? text : null;
|
|
}
|
|
|
|
function normalizeOtelSpanId(value) {
|
|
const text = String(value ?? "").trim().toLowerCase();
|
|
return /^[0-9a-f]{16}$/u.test(text) && text !== ZERO_SPAN_ID ? text : null;
|
|
}
|
|
|
|
function parseOtelTraceparent(value) {
|
|
const match = String(value ?? "").trim().toLowerCase().match(/^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/u);
|
|
if (!match) return null;
|
|
const traceId = normalizeOtelTraceId(match[1]);
|
|
const spanId = normalizeOtelSpanId(match[2]);
|
|
return traceId && spanId ? { traceId, spanId } : null;
|
|
}
|