Files
pikasTech-unidesk/scripts/src/hwlab-node-web-sentinel-otel.ts
T

145 lines
5.6 KiB
TypeScript

// SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-01-p15-cadence-otel.
// Responsibility: Best-effort OTLP span emitter for web-probe sentinel scheduler, cadence and quick-verify events.
import { randomBytes } from "node:crypto";
export interface SentinelOtelContext {
readonly node: string;
readonly lane: string;
readonly sentinelId: string;
readonly namespace?: string | null;
readonly runtime?: Record<string, unknown>;
readonly cicd?: Record<string, unknown>;
}
export function emitWebProbeSentinelSpan(context: SentinelOtelContext, name: string, attributes: Record<string, unknown> = {}, ok = true): void {
const config = resolveOtelConfig(context);
if (!config.enabled || config.endpoint === null) return;
const start = BigInt(Date.now()) * 1_000_000n;
const end = start + 1_000_000n;
const traceId = randomHex(16);
const spanId = randomHex(8);
const payload = {
resourceSpans: [{
resource: {
attributes: otelAttributes({
"service.name": config.serviceName,
"deployment.environment": context.lane,
"unidesk.node": context.node,
"hwlab.lane": context.lane,
"k8s.namespace.name": context.namespace ?? stringAtNullable(context.runtime, "namespace"),
"unidesk.values_redacted": true,
}),
},
scopeSpans: [{
scope: { name: "unidesk.web_probe_sentinel", version: "PJ2026-01060508" },
spans: [{
traceId,
spanId,
name,
kind: 1,
startTimeUnixNano: start.toString(),
endTimeUnixNano: end.toString(),
attributes: otelAttributes({
"unidesk.node": context.node,
"hwlab.lane": context.lane,
"sentinelId": context.sentinelId,
"valuesRedacted": true,
...attributes,
}),
status: { code: ok ? 1 : 2 },
}],
}],
}],
};
void fetch(config.endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => undefined);
}
export function webProbeSentinelOtelSummary(context: SentinelOtelContext): Record<string, unknown> {
const config = resolveOtelConfig(context);
return {
enabled: config.enabled,
endpointConfigured: config.endpoint !== null,
serviceName: config.serviceName,
coverage: config.enabled && config.endpoint !== null ? "best-effort-otlp-spans" : "instrumentation-gap",
expectedSpans: [
"web_probe_sentinel.scheduler.heartbeat",
"web_probe_sentinel.cadence.expected",
"web_probe_sentinel.cadence.cronjob_rendered",
"web_probe_sentinel.cadence.cronjob_observed",
"web_probe_sentinel.manual_trigger",
"web_probe_sentinel.quick_verify.job_start",
"web_probe_sentinel.quick_verify.job_finish",
"web_probe_sentinel.record_run",
"web_probe_sentinel.scheduler_gap.detected",
],
valuesRedacted: true,
};
}
function resolveOtelConfig(context: SentinelOtelContext): { readonly enabled: boolean; readonly endpoint: string | null; readonly serviceName: string } {
const runtime = context.runtime ?? {};
const cicd = context.cicd ?? {};
const enabledFromYaml = booleanAtNullable(runtime, "observability.otel.enabled")
?? booleanAtNullable(cicd, "observability.otel.enabled");
const disabledByEnv = /^(1|true)$/iu.test(process.env.OTEL_SDK_DISABLED ?? "");
const endpoint = stringAtNullable(runtime, "observability.otel.tracesEndpoint")
?? stringAtNullable(runtime, "observability.otel.endpoint")
?? stringAtNullable(cicd, "observability.otel.tracesEndpoint")
?? stringAtNullable(cicd, "observability.otel.endpoint")
?? nonEmptyString(process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT);
const serviceName = stringAtNullable(runtime, "observability.otel.serviceName")
?? stringAtNullable(cicd, "observability.otel.serviceName")
?? nonEmptyString(process.env.OTEL_SERVICE_NAME)
?? `hwlab-web-probe-sentinel-${context.node.toLowerCase()}`;
return {
enabled: !disabledByEnv && (enabledFromYaml === true || endpoint !== null),
endpoint,
serviceName,
};
}
function otelAttributes(values: Record<string, unknown>): readonly Record<string, unknown>[] {
return Object.entries(values)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => ({ key, value: otelValue(value) }));
}
function otelValue(value: unknown): Record<string, unknown> {
if (typeof value === "boolean") return { boolValue: value };
if (typeof value === "number" && Number.isFinite(value)) {
return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
}
return { stringValue: typeof value === "string" ? value : JSON.stringify(value) };
}
function randomHex(bytes: number): string {
return randomBytes(bytes).toString("hex");
}
function stringAtNullable(value: unknown, path: string): string | null {
const found = valueAtPath(value, path);
return typeof found === "string" && found.length > 0 ? found : null;
}
function booleanAtNullable(value: unknown, path: string): boolean | null {
const found = valueAtPath(value, path);
return typeof found === "boolean" ? found : null;
}
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function valueAtPath(value: unknown, path: string): unknown {
let current: unknown = value;
for (const segment of path.split(".")) {
if (typeof current !== "object" || current === null || Array.isArray(current)) return undefined;
current = (current as Record<string, unknown>)[segment];
}
return current;
}