From c87774e72f054e9d55c16e83da328d34ad5dfe32 Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:33:44 +0300 Subject: [PATCH 1/3] feat: added log-to-metrics capability --- helm/Chart.yaml | 2 +- helm/config/custom.lua | 128 ++++++++++++++++++ helm/config/log_format.conf | 79 ----------- helm/templates/nginx/nginx-configmap.yaml | 2 +- helm/values.yaml | 158 +++++++++++++++++----- 5 files changed, 257 insertions(+), 112 deletions(-) create mode 100644 helm/config/custom.lua delete mode 100644 helm/config/log_format.conf diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 2894037..1f69e61 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -7,7 +7,7 @@ appVersion: 2.1.1 dependencies: - name: nginx version: 2.3.1 - repository: oci://acrarolibotnonprod.azurecr.io/helm/common + repository: file:///home/oferade/repos/libot/nginx/helm/ - name: mclabels version: 1.0.1 repository: oci://acrarolibotnonprod.azurecr.io/helm/infra diff --git a/helm/config/custom.lua b/helm/config/custom.lua new file mode 100644 index 0000000..a3fdaee --- /dev/null +++ b/helm/config/custom.lua @@ -0,0 +1,128 @@ +------------------------------------------------------------------------------- +-- Helper Functions +------------------------------------------------------------------------------- + +local function url_decode(str) + if not str then return "" end + local decoded = str:gsub("+", " "):gsub("%%(%x%x)", function(h) + return string.char(tonumber(h, 16)) + end) + return decoded +end + +local function parse_query_string(query) + local params = {} + if not query or query == "" then return params end + for k, v in query:gmatch("([^&]+)=([^&]+)") do + params[string.lower(k)] = url_decode(v) + end + return params +end + +local function normalize_format(format_str) + if not format_str then return nil end + return string.lower(format_str):gsub("^image/", "") +end + +------------------------------------------------------------------------------- +-- Parsers (KVP and RESTful) +------------------------------------------------------------------------------- + +local function parse_kvp(params) + local service = params["service"] and string.upper(params["service"]) + local operation = params["request"] and string.upper(params["request"]) + local layer = params["layers"] or params["layer"] + local format = normalize_format(params["format"]) + local zoom = nil + + if params["tilematrix"] then + zoom = params["tilematrix"]:match(":(%d+)$") or + params["tilematrix"]:match("^(%d+)$") or + params["tilematrix"] + end + + if not service and operation then + if operation == "GETMAP" or operation == "GETFEATUREINFO" then + service = "WMS" + elseif operation == "GETTILE" then + service = "WMTS" + end + end + + return service, operation, layer, zoom, format +end + +local function parse_restful(path) + local lower_path = string.lower(path) + + if lower_path:find("/wmts/") then + local pattern = "/wmts/([^/]+)/([^/]+)/(%d+)/(%d+)/(%d+)%.(%w+)" + local layer, grid, zoom, x, y, format = path:match(pattern) + + if layer then + return "WMTS", "GETTILE", layer, zoom, normalize_format(format) + elseif lower_path:find("wmtscapabilities%.xml$") then + return "WMTS", "GETCAPABILITIES", nil, nil, "xml" + end + end + + return nil, nil, nil, nil, nil +end + +-- Bucket a raw zoom level so the WMTS counter's cardinality doesn't scale with +-- every individual zoom on top of layer count: 0-14 (overview/regional), +-- 15-18 (street level), 19+ (max detail). +local function zoom_group(zoom) + local z = tonumber(zoom) + if z == nil then return "none" end + if z <= 14 then return "0-14" end + if z <= 18 then return "15-18" end + return "19+" +end + +------------------------------------------------------------------------------- +-- Main Fluent Bit Entrypoint +------------------------------------------------------------------------------- + +function parse_mapproxy_request(tag, timestamp, record) + -- The URL lives at Attributes["url.path"] / Attributes["url.query"] - + -- Fluent Bit's json parser never produces a top-level "request_uri". + local attrs = record["Attributes"] or {} + local path = attrs["url.path"] or "" + local query_string = attrs["url.query"] or "" + + local params = parse_query_string(query_string) + + local service, operation, layer, zoom, format + + if query_string ~= "" then + service, operation, layer, zoom, format = parse_kvp(params) + end + + if not service then + service, operation, layer, zoom, format = parse_restful(path) + end + + if not service or not operation then + return 0, 0, 0 + end + + record["ogc_service"] = service + record["ogc_operation"] = operation + record["ogc_layer"] = layer or "none" + record["ogc_zoom"] = zoom or "none" + record["ogc_format"] = format or "none" + record["ogc_zoom_group"] = zoom_group(zoom) + + -- result (ok/error) + a numeric request_time, for the latency histograms + -- below. Both come off nginx as strings ("200", "0.043"). + local status = tonumber(attrs["http.response.status_code"]) + record["result"] = (status ~= nil and status >= 400) and "error" or "ok" + + local request_time = tonumber(attrs["mapcolonies.request_time"]) + if request_time ~= nil then + record["request_time_seconds"] = request_time + end + + return 1, timestamp, record +end diff --git a/helm/config/log_format.conf b/helm/config/log_format.conf deleted file mode 100644 index f6e570d..0000000 --- a/helm/config/log_format.conf +++ /dev/null @@ -1,79 +0,0 @@ -map $msec $nanosec { - ~(.*)\.(.*) $1$2000000; -} - -log_format main escape=json - '{' - '"Timestamp": $nanosec,' - '"Attributes": {' - '"mapcolonies.time_local":"$time_local",' - {{ if and (.Values.global.opalaEnabled) (.Values.nginx.authorization.enabled) }} - '"mapcolonies.http.auth.token.client_name":"$jwt_payload_sub",' - {{ end }} - '"http.status_code": $status,' - '"http.url": "$http_referer",' - '"http.x_forwarded_for": "$http_x_forwarded_for",' - '"http.proxy_add_x_forwarded_for": "$proxy_add_x_forwarded_for",' - '"http.user_agent": "$http_user_agent",' - '"http.request_time": $request_time,' - '"request_length": "$request_length",' - '"http.host": "$host",' - '"upstream_addr": "$upstream_addr",' - '"upstream_status": "$upstream_status",' - '"upstream_connect_time": "$upstream_connect_time",' - '"upstream_header_time": "$upstream_header_time",' - '"upstream_response_time": "$upstream_response_time",' - '"upstream_response_length": "$upstream_response_length",' - '"upstream_bytes_sent": "$upstream_bytes_sent",' - '"upstream_bytes_received": "$upstream_bytes_received",' - '"upstream_http_name": "$upstream_http_name",' - '"bytes_sent": "$bytes_sent",' - '"body_bytes_sent": "$body_bytes_sent",' - '"content_type":"$content_type",' - '"content_length":"$content_length",' - '"remote_addr": "$remote_addr",' - '"remote_port": "$remote_port",' - '"remote_user": "$remote_user",' - '"connection":"$connection",' - '"cache_status":"$upstream_cache_status",' - '"connection_requests": "$connection_requests",' - '"connections_active": "$connections_active",' - '"connections_reading": "$connections_reading",' - '"connections_writing": "$connections_writing",' - '"connections_waiting": "$connections_waiting",' - '"ancient_browser": "$ancient_browser",' - '"modern_browser": "$modern_browser",' - '"binary_remote_addr": "$binary_remote_addr",' - '"hostname": "$hostname",' - '"http_name": "$http_name",' - '"pid": "$pid",' - '"proxy_protocol_addr": "$proxy_protocol_addr",' - '"proxy_protocol_port": "$proxy_protocol_port",' - '"proxy_protocol_server_addr": "$proxy_protocol_server_addr",' - '"proxy_protocol_server_port": "$proxy_protocol_server_port",' - '"proxy_host": "$proxy_host",' - '"proxy_port": "$proxy_port",' - '"realip_remote_addr": "$realip_remote_addr",' - '"realip_remote_port": "$realip_remote_port",' - '"server_name": "$server_name",' - '"server_addr": "$server_addr",' - '"server_port": "$server_port",' - '"server_protocol": "$server_protocol",' - '"tcpinfo_rtt": "$tcpinfo_rtt",' - '"tcpinfo_rttvar": "$tcpinfo_rttvar",' - '"tcpinfo_snd_cwnd": "$tcpinfo_snd_cwnd",' - '"tcpinfo_rcv_space": "$tcpinfo_rcv_space",' - '"uid_got": "$uid_got",' - '"uid_reset": "$uid_reset",' - '"uid_set": "$uid_set"' - '},' - '"TraceId": "$otel_trace_id",' ## this is a byte sequence (hex-encoded in JSON) - '"SpanId": "$otel_span_id",' - {{ if .Values.nginx.authorization.enabled }} - '"TokenUser": "$jwt_payload_sub",' - {{ end }} - '"SeverityText": "INFO",' - '"SeverityNumber": 9,' - '"RequestBody": "$request_body",' - '"Body": "$request"' - '}'; diff --git a/helm/templates/nginx/nginx-configmap.yaml b/helm/templates/nginx/nginx-configmap.yaml index 5b70951..d1bbc5d 100644 --- a/helm/templates/nginx/nginx-configmap.yaml +++ b/helm/templates/nginx/nginx-configmap.yaml @@ -12,10 +12,10 @@ metadata: environment: {{ .Values.environment }} release: {{ $releaseName }} data: - log_format.conf: {{ tpl (.Files.Get "config/log_format.conf") . | quote }} nginx.mapproxy.conf: {{ tpl (.Files.Get "config/nginx.mapproxy.conf") . | quote }} nginx.mapproxy.wmts.conf: {{ tpl (.Files.Get "config/nginx.mapproxy.wmts.conf") . | quote }} mapproxy-http.conf: {{ tpl (.Files.Get "config/mapproxy-http.conf") . | quote }} mapproxy-server.conf: {{ tpl (.Files.Get "config/mapproxy-server.conf") . | quote }} mapproxy-location.conf: {{ tpl (.Files.Get "config/mapproxy-location.conf") . | quote }} + custom.lua: {{ tpl (.Files.Get "config/custom.lua") . | quote }} {{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index a9fb67d..dd3c54e 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -288,43 +288,40 @@ nginx: domain: "raster" extraVolumes: - - name: nginx-config - configMap: - # must match nginx-configmap.fullname (--nginx-configmap); - # tpl runs in the nginx subchart context, so the chart name is spelled out - name: "{{ .Release.Name }}-mapproxy-nginx-configmap" - - name: cache #TODO: Find solution to mount cache volume with condition - emptyDir: - sizeLimit: "{{ .Values.volume.storage }}" - medium: "Memory" + - name: nginx-config + configMap: + # must match nginx-configmap.fullname (--nginx-configmap); + # tpl runs in the nginx subchart context, so the chart name is spelled out + name: "{{ .Release.Name }}-mapproxy-nginx-configmap" + - name: cache #TODO: Find solution to mount cache volume with condition + emptyDir: + sizeLimit: "{{ .Values.volume.storage }}" + medium: "Memory" extraVolumeMounts: - - name: nginx-config - mountPath: "/etc/nginx/log_format.conf" - subPath: log_format.conf - - name: nginx-config - mountPath: "/etc/nginx/nginx.mapproxy.conf" - subPath: nginx.mapproxy.conf - - name: nginx-config - mountPath: "/etc/nginx/nginx.mapproxy.wmts.conf" - subPath: nginx.mapproxy.wmts.conf - - name: nginx-config - mountPath: "/etc/nginx/conf.d/mapproxy-http.conf" - subPath: mapproxy-http.conf - - name: nginx-config - mountPath: "/etc/nginx/conf.d/extensions/mapproxy-server.conf" - subPath: mapproxy-server.conf - - name: nginx-config - mountPath: "/etc/nginx/conf.d/extensions/mapproxy-location.conf" - subPath: mapproxy-location.conf - - name: cache - mountPath: "/cache" + - name: nginx-config + mountPath: "/etc/nginx/nginx.mapproxy.conf" + subPath: nginx.mapproxy.conf + - name: nginx-config + mountPath: "/etc/nginx/nginx.mapproxy.wmts.conf" + subPath: nginx.mapproxy.wmts.conf + - name: nginx-config + mountPath: "/etc/nginx/conf.d/mapproxy-http.conf" + subPath: mapproxy-http.conf + - name: nginx-config + mountPath: "/etc/nginx/conf.d/extensions/mapproxy-server.conf" + subPath: mapproxy-server.conf + - name: nginx-config + mountPath: "/etc/nginx/conf.d/extensions/mapproxy-location.conf" + subPath: mapproxy-location.conf + - name: cache + mountPath: "/cache" route: enabled: true routesMapping: - - host: - path: /api/raster/v1 + - host: + path: /api/raster/v1 timeout: enabled: false # defaults to 30s by openshift duration: 60s # supported units (us, ms, s, m, h, d) @@ -384,6 +381,105 @@ nginx: requests: cpu: 100m memory: 128Mi + fluentbit: + enabled: true + debug: + enabled: true + stages: + received: false + parsed: true + output: + logs: + host: infra-alloy.infra-dev.svc.cluster.local + accessLog: + metrics: + filters: + - name: stdout + match: nginx.access + - name: log_to_metrics + match: nginx.access + tag: nginx.metrics + metric_mode: counter + metric_namespace: mapproxy + metric_subsystem: wms + metric_name: requests_total + metric_description: Total WMS requests + regex: ogc_service ^WMS$ + label_field: + - ogc_operation + - ogc_layer + - ogc_format + - result + + - name: log_to_metrics + match: nginx.access + tag: nginx.metrics + metric_mode: counter + metric_namespace: mapproxy + metric_subsystem: wmts + metric_name: requests_total + metric_description: Total WMTS requests + regex: ogc_service ^WMTS$ + add_label: client_name $Attributes['mapcolonies.http.auth.token.client_name'] + label_field: + - ogc_operation + - ogc_layer + - ogc_zoom_group + - ogc_format + - result + + # Error-rate counter: covers ALL traffic, not just requests that matched + # WMS/WMTS - answers "is the endpoint healthy" regardless of OGC extraction. + # Labeled by exact status_code; few distinct codes actually occur, so this + # stays cheap. + - name: log_to_metrics + match: nginx.access + tag: nginx.metrics + metric_mode: counter + metric_namespace: nginx + metric_subsystem: http + metric_name: errors_total + metric_description: Total nginx HTTP error responses by status code + regex: $Attributes['http.response.status_code'] ^[45] + add_label: status_code $Attributes['http.response.status_code'] + + # Latency histograms: protocol-split like the counters, but the only label + # is result (ok/error) - kept as a separate label rather than dropped, + # since a slow upstream timeout (502/504) and a fast validation failure + # (400) are different problems and both are worth seeing. No layer/zoom - + # a histogram is many bucket-series per label combination already. + - name: log_to_metrics + match: nginx.access + tag: nginx.metrics + metric_mode: histogram + metric_namespace: mapproxy + metric_subsystem: wms + metric_name: request_duration_seconds + metric_description: WMS request latency + value_field: request_time_seconds + regex: ogc_service ^WMS$ + label_field: + - result + + - name: log_to_metrics + match: nginx.access + tag: nginx.metrics + metric_mode: histogram + metric_namespace: mapproxy + metric_subsystem: wmts + metric_name: request_duration_seconds + metric_description: WMTS request latency + value_field: request_time_seconds + regex: ogc_service ^WMTS$ + label_field: + - result + lua: + enabled: true + configMap: + name: "{{ .Release.Name }}-mapproxy-nginx-configmap" + key: custom.lua + calls: + allRecords: parse_mapproxy_request mapproxinator: enabled: true From 286050327144a148f66bc94be5ea07f781d1b8e4 Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:36:53 +0300 Subject: [PATCH 2/3] chore: remove stdout --- helm/values.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/helm/values.yaml b/helm/values.yaml index dd3c54e..ac17ca5 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -394,8 +394,6 @@ nginx: accessLog: metrics: filters: - - name: stdout - match: nginx.access - name: log_to_metrics match: nginx.access tag: nginx.metrics From b8eb10d2ea8fe455294dda70842f185960ea5f99 Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:55:38 +0300 Subject: [PATCH 3/3] feat: updated to newest nginx version --- helm/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 1f69e61..ba647ae 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -6,8 +6,8 @@ version: 2.1.1 appVersion: 2.1.1 dependencies: - name: nginx - version: 2.3.1 - repository: file:///home/oferade/repos/libot/nginx/helm/ + version: 2.4.0 + repository: oci://acrarolibotnonprod.azurecr.io/helm/common - name: mclabels version: 1.0.1 repository: oci://acrarolibotnonprod.azurecr.io/helm/infra