Files
pikasTech-unidesk/scripts/native/cicd/otel-runtime-config.ts
T
2026-07-13 15:41:37 +02:00

235 lines
8.4 KiB
TypeScript

import { rootPath } from "../../src/config";
import { readYamlRecord } from "../../src/platform-infra-ops-library";
import { materializeYamlComposition } from "../../src/yaml-composition";
const pacConfigPath = "config/platform-infra/pipelines-as-code.yaml";
export interface CicdOtelRuntimeConfig {
readonly endpoint: string;
readonly endpointHostname: string;
readonly serviceName: string;
readonly samplingRatio: number;
readonly timeoutMs: number;
readonly fallbackCodes: readonly string[];
}
export interface CicdOtelExportWarning {
readonly event: "cicd.otel.export";
readonly warning: true;
readonly blocking: false;
readonly causeCode: string;
readonly endpointHostname: string;
readonly httpStatus?: number;
readonly timedOut?: boolean;
readonly errorType?: string;
readonly timeoutMs: number;
readonly traceId: string;
readonly valuesRedacted: true;
}
export function loadCicdOtelRuntimeConfig(
node: string,
lane: string,
env: Record<string, string | undefined> = process.env,
): CicdOtelRuntimeConfig {
const root = materializeYamlComposition(
readYamlRecord<Record<string, unknown>>(rootPath(pacConfigPath), "platform-infra-pipelines-as-code"),
{ label: "platform-infra-pipelines-as-code", stripTemplateKeys: true },
).value;
const observability = record(root.observability, `${pacConfigPath}#observability`);
const repository = repositoryFor(root.repositories, node, lane);
const params = record(repository.params, `${pacConfigPath}#repositories.${repository.id}.params`);
const fallbackCodes: string[] = [];
const endpoint = authoritativeValue(
[
[params.otel_traces_endpoint, "INVALID_ENDPOINT_PARAM"],
[observability.tracesEndpoint, "INVALID_ENDPOINT_AUTHORITY"],
],
validEndpoint,
fallbackCodes,
);
validateInput(env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, endpoint, validEndpoint, "ENDPOINT", fallbackCodes);
const endpointUrl = new URL(endpoint);
const serviceName = authoritativeValue(
[
[params.otel_service_name, "INVALID_SERVICE_PARAM"],
[observability.serviceName, "INVALID_SERVICE_AUTHORITY"],
],
validServiceName,
fallbackCodes,
);
validateInput(env.OTEL_SERVICE_NAME, serviceName, validServiceName, "SERVICE", fallbackCodes);
const sampling = record(observability.sampling, `${pacConfigPath}#observability.sampling`);
const samplingRatio = authoritativeNumber(
[
[params.otel_sampling_ratio, "INVALID_SAMPLING_PARAM"],
[sampling.ratio, "INVALID_SAMPLING_AUTHORITY"],
],
(value) => value >= 0 && value <= 1,
fallbackCodes,
);
validateNumberInput(env.OTEL_TRACES_SAMPLER_ARG, samplingRatio, "SAMPLING", fallbackCodes);
const exporterFailure = record(observability.exporterFailure, `${pacConfigPath}#observability.exporterFailure`);
const timeoutMs = authoritativeNumber(
[
[params.otel_exporter_timeout_ms, "INVALID_TIMEOUT_PARAM"],
[exporterFailure.timeoutMs, "INVALID_TIMEOUT_AUTHORITY"],
],
(value) => Number.isInteger(value) && value >= 50 && value <= 2_000,
fallbackCodes,
);
validateNumberInput(env.OTEL_EXPORTER_TIMEOUT_MS, timeoutMs, "TIMEOUT", fallbackCodes);
return {
endpoint: endpointUrl.toString(),
endpointHostname: endpointUrl.hostname,
serviceName,
samplingRatio,
timeoutMs,
fallbackCodes,
};
}
export async function exportCicdOtelPayload(
config: CicdOtelRuntimeConfig,
traceparent: string,
traceId: string,
payload: unknown,
fetcher: typeof fetch = fetch,
): Promise<CicdOtelExportWarning | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
try {
const response = await fetcher(config.endpoint, {
method: "POST",
headers: { "content-type": "application/json", traceparent },
body: JSON.stringify(payload),
signal: controller.signal,
});
if (response.ok) return null;
return warning(config, traceId, `HTTP_${response.status}`, { httpStatus: response.status });
} catch (error) {
return warning(config, traceId, errorCauseCode(error), {
timedOut: error instanceof Error && error.name === "AbortError",
errorType: error instanceof Error ? error.name : "Error",
});
} finally {
clearTimeout(timer);
}
}
function repositoryFor(value: unknown, node: string, lane: string): Record<string, unknown> {
if (!Array.isArray(value)) throw new Error(`${pacConfigPath}#repositories must be an array`);
const expectedId = `sentinel-${node.toLowerCase()}-${lane.toLowerCase()}`;
const repository = value.find((item) => recordOrNull(item)?.id === expectedId);
if (repository === undefined) throw new Error(`${pacConfigPath} has no repository ${expectedId}`);
return record(repository, `${pacConfigPath}#repositories.${expectedId}`);
}
function authoritativeValue(
candidates: ReadonlyArray<readonly [unknown, string]>,
validate: (value: string) => boolean,
fallbackCodes: string[],
): string {
for (const [candidate, code] of candidates) {
if (candidate === undefined || candidate === null || candidate === "") {
fallbackCodes.push(code);
continue;
}
if (typeof candidate === "string" && validate(candidate)) return candidate;
fallbackCodes.push(code);
}
throw new Error(`invalid owning OTel configuration: ${fallbackCodes.at(-1) ?? "UNKNOWN"}`);
}
function authoritativeNumber(
candidates: ReadonlyArray<readonly [unknown, string]>,
validate: (value: number) => boolean,
fallbackCodes: string[],
): number {
for (const [candidate, code] of candidates) {
if (candidate === undefined || candidate === null || candidate === "") {
fallbackCodes.push(code);
continue;
}
const value = typeof candidate === "number" ? candidate : Number(candidate);
if (Number.isFinite(value) && validate(value)) return value;
fallbackCodes.push(code);
}
throw new Error(`invalid owning OTel configuration: ${fallbackCodes.at(-1) ?? "UNKNOWN"}`);
}
function validateInput(
input: string | undefined,
desired: string,
validate: (value: string) => boolean,
label: string,
fallbackCodes: string[],
): void {
if (input === undefined || input === "") return;
if (!validate(input)) fallbackCodes.push(`INVALID_${label}_INPUT`);
else if (input !== desired) fallbackCodes.push(`MISMATCH_${label}_INPUT`);
}
function validateNumberInput(input: string | undefined, desired: number, label: string, fallbackCodes: string[]): void {
if (input === undefined || input === "") return;
const value = Number(input);
if (!Number.isFinite(value)) fallbackCodes.push(`INVALID_${label}_INPUT`);
else if (input !== String(desired)) fallbackCodes.push(`MISMATCH_${label}_INPUT`);
}
function validEndpoint(value: string): boolean {
if (hasTemplatePlaceholder(value)) return false;
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:")
&& url.hostname.length > 0
&& url.username.length === 0
&& url.password.length === 0;
} catch {
return false;
}
}
function validServiceName(value: string): boolean {
return !hasTemplatePlaceholder(value) && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);
}
function hasTemplatePlaceholder(value: string): boolean {
return /\{\{[^}]+\}\}|\$\{[^}]+\}/u.test(value);
}
function warning(
config: CicdOtelRuntimeConfig,
traceId: string,
causeCode: string,
details: Pick<CicdOtelExportWarning, "httpStatus" | "timedOut" | "errorType">,
): CicdOtelExportWarning {
return {
event: "cicd.otel.export",
warning: true,
blocking: false,
causeCode,
endpointHostname: config.endpointHostname,
...details,
timeoutMs: config.timeoutMs,
traceId,
valuesRedacted: true,
};
}
function errorCauseCode(error: unknown): string {
if (error instanceof Error && error.name === "AbortError") return "EXPORT_TIMEOUT";
const cause = error instanceof Error && typeof error.cause === "object" && error.cause !== null ? error.cause as Record<string, unknown> : null;
return typeof cause?.code === "string" && /^[A-Za-z0-9_.-]{1,80}$/u.test(cause.code) ? cause.code : "EXPORT_FAILED";
}
function record(value: unknown, path: string): Record<string, unknown> {
const result = recordOrNull(value);
if (result === null) throw new Error(`${path} must be an object`);
return result;
}
function recordOrNull(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}