Files
pikasTech-HWLAB/internal/cloud/otel-trace.ts
T

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;
}