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 = process.env, ): CicdOtelRuntimeConfig { const root = materializeYamlComposition( readYamlRecord>(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 { 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 { 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, 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, 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 { 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 : 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 { const result = recordOrNull(value); if (result === null) throw new Error(`${path} must be an object`); return result; } function recordOrNull(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; }