145 lines
5.6 KiB
TypeScript
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;
|
|
}
|