Files
pikasTech-agentrun/src/common/otel-trace.ts
T
2026-07-10 10:19:36 +02:00

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