147 lines
6.8 KiB
TypeScript
147 lines
6.8 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import type { CommandRecord, JsonRecord, RunRecord } from "./types.js";
|
|
|
|
const OTLP_TIMEOUT_MS = 1500;
|
|
const ZERO_TRACE_ID = "00000000000000000000000000000000";
|
|
const ZERO_SPAN_ID = "0000000000000000";
|
|
|
|
export function agentRunBusinessTraceId(run: RunRecord | null | undefined, command?: CommandRecord | null): string | null {
|
|
const traceSink = asRecord(run?.traceSink);
|
|
const sessionMetadata = asRecord(run?.sessionRef?.metadata);
|
|
const commandPayload = asRecord(command?.payload);
|
|
for (const value of [
|
|
commandPayload?.traceId,
|
|
traceSink?.traceId,
|
|
traceSink?.businessTraceId,
|
|
sessionMetadata?.hwlabTraceId,
|
|
sessionMetadata?.traceId,
|
|
]) {
|
|
const text = typeof value === "string" ? value.trim() : "";
|
|
if (/^trc_[A-Za-z0-9_.:-]+$/u.test(text)) return text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function agentRunOtelTraceContext(run: RunRecord | null | undefined, command?: CommandRecord | null) {
|
|
const businessTraceId = agentRunBusinessTraceId(run, command) ?? `run_${String(run?.id ?? "unknown")}`;
|
|
const traceId = nonZeroHex(createHash("sha256").update(`hwlab-code-agent:${businessTraceId}`).digest("hex").slice(0, 32), ZERO_TRACE_ID);
|
|
const parentSpanId = nonZeroHex(createHash("sha256").update(`agentrun-manager-parent:${businessTraceId}`).digest("hex").slice(0, 16), ZERO_SPAN_ID);
|
|
return { businessTraceId, traceId, parentSpanId, traceparent: `00-${traceId}-${parentSpanId}-01`, valuesPrinted: false };
|
|
}
|
|
|
|
export async function emitAgentRunOtelSpan(name: string, run: RunRecord | null | undefined, env: NodeJS.ProcessEnv = process.env, options: { command?: CommandRecord | null; startTimeMs?: number; endTimeMs?: number; status?: "ok" | "error"; error?: unknown; attributes?: JsonRecord; kind?: number; scopeName?: string } = {}): Promise<JsonRecord> {
|
|
const endpoint = resolveOtlpTracesEndpoint(env);
|
|
if (!endpoint || typeof fetch !== "function") return { ok: false, skipped: true, reason: "otlp-endpoint-missing", valuesPrinted: false };
|
|
const context = agentRunOtelTraceContext(run, options.command ?? null);
|
|
const startedAtMs = Number.isFinite(Number(options.startTimeMs)) ? Number(options.startTimeMs) : Date.now();
|
|
const endedAtMs = Number.isFinite(Number(options.endTimeMs)) ? Number(options.endTimeMs) : Date.now();
|
|
const spanId = nonZeroHex(randomBytes(8).toString("hex"), ZERO_SPAN_ID);
|
|
const statusCode = options.status === "error" || options.error ? 2 : 1;
|
|
const resource = resourceAttributes(env, run);
|
|
const body = {
|
|
resourceSpans: [{
|
|
resource: { attributes: attributesFromRecord(resource) },
|
|
scopeSpans: [{
|
|
scope: { name: options.scopeName ?? scopeNameFromResource(resource), version: "1" },
|
|
spans: [{
|
|
traceId: context.traceId,
|
|
spanId,
|
|
parentSpanId: context.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,
|
|
"agentrun.stage": name,
|
|
runId: run?.id ?? null,
|
|
commandId: options.command?.id ?? null,
|
|
sessionId: run?.sessionRef?.sessionId ?? null,
|
|
...options.attributes,
|
|
valuesPrinted: false,
|
|
}),
|
|
status: {
|
|
code: statusCode,
|
|
...(options.error ? { message: String((options.error as 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 as Error)?.name === "AbortError" ? "otlp-timeout" : "otlp-send-failed", valuesPrinted: false };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function resolveOtlpTracesEndpoint(env: NodeJS.ProcessEnv): string | null {
|
|
const explicit = firstNonEmpty(env.AGENTRUN_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT);
|
|
if (explicit) return explicit.replace(/\/+$/u, "");
|
|
const base = firstNonEmpty(env.AGENTRUN_OTEL_EXPORTER_OTLP_ENDPOINT, env.OTEL_EXPORTER_OTLP_ENDPOINT);
|
|
return base ? `${base.replace(/\/+$/u, "")}/v1/traces` : null;
|
|
}
|
|
|
|
function resourceAttributes(env: NodeJS.ProcessEnv, run: RunRecord | null | undefined): JsonRecord {
|
|
return {
|
|
"service.name": firstNonEmpty(env.OTEL_SERVICE_NAME, "agentrun-manager"),
|
|
"deployment.environment": firstNonEmpty(env.AGENTRUN_LANE, "unknown"),
|
|
"unidesk.node": firstNonEmpty(env.UNIDESK_NODE_ID, env.AGENTRUN_NODE_ID, "unknown"),
|
|
"hwlab.lane": firstNonEmpty(env.HWLAB_RUNTIME_LANE, stringValue(asRecord(run?.traceSink)?.hwlabLane), "unknown"),
|
|
"k8s.namespace.name": firstNonEmpty(env.POD_NAMESPACE, env.AGENTRUN_RUNTIME_NAMESPACE, "unknown"),
|
|
"git.commit": firstNonEmpty(env.AGENTRUN_SOURCE_COMMIT, "unknown"),
|
|
};
|
|
}
|
|
|
|
function scopeNameFromResource(resource: JsonRecord): string {
|
|
return resource["service.name"] === "agentrun-runner" ? "agentrun.runner" : "agentrun.manager";
|
|
}
|
|
|
|
function attributesFromRecord(record: JsonRecord): Array<{ key: string; value: JsonRecord }> {
|
|
return Object.entries(record)
|
|
.filter(([, value]) => value !== undefined && value !== null)
|
|
.map(([key, value]) => ({ key, value: otlpAnyValue(value) }));
|
|
}
|
|
|
|
function otlpAnyValue(value: unknown): JsonRecord {
|
|
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: number): string {
|
|
return String(Math.trunc(ms * 1_000_000));
|
|
}
|
|
|
|
function firstNonEmpty(...values: Array<string | null | undefined>): string | null {
|
|
for (const value of values) {
|
|
const text = String(value ?? "").trim();
|
|
if (text) return text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function stringValue(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function asRecord(value: unknown): JsonRecord | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null;
|
|
}
|
|
|
|
function nonZeroHex(value: string, zero: string): string {
|
|
return /^[0-9a-f]+$/u.test(value) && value !== zero ? value : zero.replace(/0$/u, "1");
|
|
}
|