diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 193524f3..28a416ca 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -161,6 +161,7 @@ lanes: XDG_CONFIG_HOME: /tekton/home/.config observability: prometheusOperator: false + traceExplorerUrlTemplate: /v1/workbench/traces/{trace_id}/events metricsEndpoint: serviceName: hwlab-cloud-api containerName: hwlab-cloud-api diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 52f8fae3..96b71815 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -93,6 +93,7 @@ export interface HwlabRuntimeBuildkitSpec { export interface HwlabRuntimeObservabilitySpec { readonly prometheusOperator: boolean; + readonly traceExplorerUrlTemplate?: string; readonly metricsEndpoint?: HwlabRuntimeObservabilityMetricsEndpointSpec; readonly workbench?: HwlabRuntimeObservabilityWorkbenchSpec; readonly recordingRules: readonly HwlabRuntimeObservabilityRecordingRuleSpec[]; @@ -641,8 +642,11 @@ function observabilityConfig(value: unknown, path: string): HwlabRuntimeObservab for (const alert of warningAlerts) { if (!recordingRuleIds.has(alert.ruleId)) throw new Error(`${path}.warningAlerts.${alert.id}.ruleId must reference a recordingRules id`); } + const traceExplorerUrlTemplate = optionalStringField(raw, "traceExplorerUrlTemplate", path); + if (traceExplorerUrlTemplate !== undefined) validateTraceExplorerUrlTemplate(traceExplorerUrlTemplate, `${path}.traceExplorerUrlTemplate`); return { prometheusOperator: booleanField(raw, "prometheusOperator", path), + traceExplorerUrlTemplate, metricsEndpoint: observabilityMetricsEndpointConfig(raw.metricsEndpoint, `${path}.metricsEndpoint`), workbench: observabilityWorkbenchConfig(raw.workbench, `${path}.workbench`), recordingRules, @@ -650,6 +654,22 @@ function observabilityConfig(value: unknown, path: string): HwlabRuntimeObservab }; } +function validateTraceExplorerUrlTemplate(template: string, path: string): void { + if (!template.includes("{trace_id}")) throw new Error(`${path} must include {trace_id}`); + if (template.startsWith("//")) throw new Error(`${path} must not be protocol-relative`); + const placeholders = Array.from(template.matchAll(/\{([A-Za-z0-9_:-]+)\}/gu), (match) => match[0]); + if (placeholders.length === 0 || placeholders.some((placeholder) => placeholder !== "{trace_id}")) { + throw new Error(`${path} may only use the {trace_id} placeholder`); + } + try { + const url = new URL(template.replaceAll("{trace_id}", "trc_template_probe"), "https://hwlab.local"); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("unsupported protocol"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${path} must be an http(s) or same-origin relative URL template: ${message}`); + } +} + function observabilityMetricsEndpointConfig(value: unknown, path: string): HwlabRuntimeObservabilityMetricsEndpointSpec | undefined { if (value === undefined) return undefined; const raw = asRecord(value, path);