From 06de9a6e33412788e4100992a048ec9fb16186af Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 2 Oct 2026 23:50:23 +0800 Subject: [PATCH 1/3] feat(observability): normalize Dubbo RPC span identity for metric correlation Enrich Dubbo RPC spans with application identity and normalize legacy dubbo-go ServiceKey interfaces without changing rpc.service. Preserve explicit group/version attributes for correlation metadata. --- .../otelCollector-configuration.yaml | 18 +- scripts/verify-trace-metric-collector.py | 178 ++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-trace-metric-collector.py diff --git a/release/kubernetes/monitoring/otelCollector-configuration.yaml b/release/kubernetes/monitoring/otelCollector-configuration.yaml index fe3c6c3ae..bcf6b58ec 100644 --- a/release/kubernetes/monitoring/otelCollector-configuration.yaml +++ b/release/kubernetes/monitoring/otelCollector-configuration.yaml @@ -39,6 +39,22 @@ data: pod_association: - sources: - from: connection + transform/dubbo_application: + error_mode: ignore + trace_statements: + - context: span + statements: + - set(attributes["dubbo.application"], resource.attributes["service.name"]) where attributes["rpc.system"] == "apache_dubbo" and attributes["rpc.service"] != nil and attributes["rpc.method"] != nil and resource.attributes["service.name"] != nil + # Keep rpc.service intact; older dubbo-go emits [group/]interface[:version]. + - set(attributes["dubbo.metric.interface"], attributes["rpc.service"]) where attributes["rpc.system"] == "apache_dubbo" and attributes["rpc.service"] != nil and attributes["rpc.service"] != "" + - replace_pattern(attributes["dubbo.metric.interface"], "^[^/]+/", "") where attributes["rpc.system"] == "apache_dubbo" and attributes["dubbo.metric.interface"] != nil + - set(attributes["dubbo.metric.version"], attributes["dubbo.metric.interface"]) where attributes["rpc.system"] == "apache_dubbo" and attributes["dubbo.version"] == nil and attributes["dubbo.metric.interface"] != nil and IsMatch(attributes["dubbo.metric.interface"], ":[^:]+$") + - replace_pattern(attributes["dubbo.metric.version"], "^.*:", "") where attributes["rpc.system"] == "apache_dubbo" and attributes["dubbo.metric.version"] != nil + - replace_pattern(attributes["dubbo.metric.interface"], ":[^:]+$", "") where attributes["rpc.system"] == "apache_dubbo" and attributes["dubbo.metric.interface"] != nil + - set(attributes["dubbo.metric.group"], attributes["rpc.service"]) where attributes["rpc.system"] == "apache_dubbo" and attributes["dubbo.group"] == nil and IsMatch(attributes["rpc.service"], "^[^/]+/.+") + - replace_pattern(attributes["dubbo.metric.group"], "/.*$", "") where attributes["rpc.system"] == "apache_dubbo" and attributes["dubbo.metric.group"] != nil + - set(attributes["dubbo.metric.group"], attributes["dubbo.group"]) where attributes["rpc.system"] == "apache_dubbo" and attributes["rpc.service"] != nil and attributes["rpc.service"] != "" and attributes["dubbo.group"] != nil and attributes["dubbo.group"] != "" + - set(attributes["dubbo.metric.version"], attributes["dubbo.version"]) where attributes["rpc.system"] == "apache_dubbo" and attributes["rpc.service"] != nil and attributes["rpc.service"] != "" and attributes["dubbo.version"] != nil and attributes["dubbo.version"] != "" exporters: otlp/jaeger: endpoint: "jaeger.monitoring.svc:4317" @@ -48,5 +64,5 @@ data: pipelines: traces: receivers: [otlp] - processors: [k8sattributes] + processors: [k8sattributes, transform/dubbo_application] exporters: [otlp/jaeger] diff --git a/scripts/verify-trace-metric-collector.py b/scripts/verify-trace-metric-collector.py new file mode 100644 index 000000000..b82d56b55 --- /dev/null +++ b/scripts/verify-trace-metric-collector.py @@ -0,0 +1,178 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Execute the release transform in Collector 0.112.0, without cluster access. + +Requires Python 3, PyYAML and a native otelcol-contrib 0.112.0 executable. +Only the isolated HTTP receiver on loopback and a temporary file exporter run. +""" + +import argparse +import json +from pathlib import Path +import socket +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + +import yaml + + +def attributes(values): + return [{"key": key, "value": {"stringValue": value}} for key, value in values.items()] + + +def read_spans(path): + spans = {} + if not path.exists(): + return spans + for line in path.read_text(encoding="utf-8").splitlines(): + try: + batch = json.loads(line) + except json.JSONDecodeError: + continue # The file exporter may still be flushing its last line. + for resource in batch.get("resourceSpans", []): + for scope in resource.get("scopeSpans", []): + for span in scope.get("spans", []): + spans[span["name"]] = { + item["key"]: item["value"].get("stringValue") + for item in span.get("attributes", []) + } + return spans + + +def verify(collector, manifest): + version = subprocess.check_output([collector, "--version"], text=True).strip() + if not version.endswith(" 0.112.0"): + raise RuntimeError(f"Expected Collector Contrib 0.112.0, got {version}") + config = yaml.safe_load(yaml.safe_load(manifest.read_text(encoding="utf-8"))["data"]["config.yaml"]) + pipeline = config["service"]["pipelines"]["traces"]["processors"] + if pipeline != ["k8sattributes", "transform/dubbo_application"]: + raise RuntimeError("Trace pipeline must execute k8sattributes then transform/dubbo_application") + transform = config["processors"]["transform/dubbo_application"] + + # Expected outputs are fixtures, not a second implementation of the transform. + cases = [ + ("PURE_INTERFACE", "org.example.EchoService", {}, "org.example.EchoService", None, None), + ("GROUP_ONLY", "gray/org.example.EchoService", {}, "org.example.EchoService", "gray", None), + ("VERSION_ONLY", "org.example.EchoService:1.0.0", {}, "org.example.EchoService", None, "1.0.0"), + ("GROUP_AND_VERSION", "gray/org.example.EchoService:1.0.0", {}, "org.example.EchoService", "gray", "1.0.0"), + ("COLON_IN_GROUP", "blue:canary/org.example.EchoService:1.0.0", {}, "org.example.EchoService", "blue:canary", "1.0.0"), + ("EXPLICIT_ATTRIBUTES", "org.example.EchoService", {"dubbo.group": "gray", "dubbo.version": "1.0.0"}, "org.example.EchoService", "gray", "1.0.0"), + ("EXPLICIT_PRECEDENCE", "gray/org.example.EchoService:1.0.0", {"dubbo.group": "blue", "dubbo.version": "2.0.0"}, "org.example.EchoService", "blue", "2.0.0"), + ("JAVA_INTERFACE", "org.apache.dubbo.samples.OrderService", {}, "org.apache.dubbo.samples.OrderService", None, None), + ] + fixtures = [] + for name, service, extra, interface, group, version in cases: + source = {"rpc.system": "apache_dubbo", "rpc.service": service, "rpc.method": "echo", **extra} + expected = {"dubbo.application": "fixture-app", "dubbo.metric.interface": interface, + "dubbo.metric.group": group, "dubbo.metric.version": version} + fixtures.append((name, source, {"service.name": "fixture-app"}, expected)) + for name, source in [ + ("NON_DUBBO", {"rpc.system": "http", "rpc.service": "example", "rpc.method": "GET"}), + ("MISSING_SERVICE", {"rpc.system": "apache_dubbo", "rpc.method": "echo"}), + ]: + fixtures.append((name, source, {"service.name": "fixture-app"}, {})) + fixtures.append(("NO_RESOURCE_NAME", {"rpc.system": "apache_dubbo", "rpc.service": "example", "rpc.method": "echo"}, {}, + {"dubbo.metric.interface": "example"})) + + payload = {"resourceSpans": []} + for index, (name, source, resource, _) in enumerate(fixtures, 1): + payload["resourceSpans"].append({ + "resource": {"attributes": attributes(resource)}, + "scopeSpans": [{"scope": {"name": "trace-metric-contract"}, "spans": [{ + "traceId": f"{index:032x}", "spanId": f"{index:016x}", "name": name, "kind": 2, + "startTimeUnixNano": "1700000000000000000", "endTimeUnixNano": "1700000000001000000", + "attributes": attributes(source), + }]}], + }) + + with tempfile.TemporaryDirectory(prefix="dubbo-trace-metric-") as directory: + root = Path(directory) + release_config = root / "release.yaml" + release_config.write_text(yaml.safe_dump(config), encoding="utf-8") + subprocess.run([collector, "validate", f"--config={release_config}"], check=True) + print("PASS RELEASE_COLLECTOR_CONFIG") + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + output = root / "traces.json" + isolated = { + "receivers": {"otlp": {"protocols": {"http": {"endpoint": f"127.0.0.1:{port}"}}}}, + "processors": {"transform/dubbo_application": transform}, + "exporters": {"file": {"path": str(output)}}, + "service": {"pipelines": {"traces": {"receivers": ["otlp"], + "processors": ["transform/dubbo_application"], "exporters": ["file"]}}}, + } + isolated_config = root / "isolated.yaml" + isolated_config.write_text(yaml.safe_dump(isolated), encoding="utf-8") + subprocess.run([collector, "validate", f"--config={isolated_config}"], check=True) + with (root / "collector.log").open("w", encoding="utf-8") as log: + process = subprocess.Popen([collector, f"--config={isolated_config}"], stdout=log, stderr=log) + try: + deadline = time.monotonic() + 20 + while True: + if process.poll() is not None: + raise RuntimeError("Isolated Collector exited before accepting traces") + request = urllib.request.Request(f"http://127.0.0.1:{port}/v1/traces", + json.dumps(payload).encode(), {"Content-Type": "application/json"}) + try: + # Bypass host proxies: the receiver is strictly local. + with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(request, timeout=2) as response: + result = json.load(response) + if result.get("partialSuccess"): + raise RuntimeError(f"Collector rejected fixture spans: {result}") + break + except urllib.error.HTTPError: + raise + except urllib.error.URLError: + if time.monotonic() >= deadline: + raise RuntimeError("Timed out waiting for isolated Collector") + time.sleep(0.1) + while time.monotonic() < deadline: + if len(read_spans(output)) == len(fixtures): + break + time.sleep(0.1) + else: + raise RuntimeError("Timed out waiting for file-exported spans") + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + actual = read_spans(output) + for name, source, _, expected in fixtures: + values = actual[name] + for key, value in source.items(): + if values.get(key) != value: + raise AssertionError(f"{name}: original {key} changed") + for key in ("dubbo.application", "dubbo.metric.interface", "dubbo.metric.group", "dubbo.metric.version"): + value = expected.get(key) + if (value is None and key in values) or (value is not None and values.get(key) != value): + raise AssertionError(f"{name}: {key}: expected {value!r}, got {values.get(key)!r}") + print(f"PASS COLLECTOR_TRANSFORM_{name}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--collector", required=True, help="Native otelcol-contrib 0.112.0 executable") + parser.add_argument("--manifest", type=Path, default=Path(__file__).resolve().parents[1] / + "release/kubernetes/monitoring/otelCollector-configuration.yaml") + options = parser.parse_args() + verify(options.collector, options.manifest) From 4b61791c0007b98de79e60e73a9f52ace0001ca9 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 2 Oct 2026 23:59:10 +0800 Subject: [PATCH 2/3] feat(observability): link Jaeger traces to Dubbo RPC metrics Provision a stable Prometheus datasource UID and Jaeger tracesToMetrics links using application, normalized interface and method identity. Add provider/consumer request-rate queries, five-minute time shifts, correlation documentation and the existing static contract verification script. --- docs/observability-trace-to-metric.md | 98 ++++++++++++++++ .../grafana-dashboardDatasources.yaml | 17 +++ scripts/verify-trace-metric-servicekey.ps1 | 109 ++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 docs/observability-trace-to-metric.md create mode 100644 scripts/verify-trace-metric-servicekey.ps1 diff --git a/docs/observability-trace-to-metric.md b/docs/observability-trace-to-metric.md new file mode 100644 index 000000000..4ac4c2cce --- /dev/null +++ b/docs/observability-trace-to-metric.md @@ -0,0 +1,98 @@ +# Trace to metric correlation + +Dubbo Admin's Application Trace view opens a Jaeger trace in Grafana Explore. On a Dubbo RPC span, Grafana's native `tracesToMetrics` links offer **Provider Request Rate** and **Consumer Request Rate**. Selecting a link opens Split Explore: the trace remains on the left and the corresponding Prometheus query appears on the right. + +The metric is the aggregate request rate for that RPC around the span's start time, **not a metric belonging only to the selected trace**. Grafana queries the interval from five minutes before to five minutes after the span. Both links are offered on each Dubbo RPC span; the link does not infer provider or consumer side from `span.kind`. + +## Data flow and identity + +The existing OTel Collector traces pipeline sends spans to Jaeger. Its transform processor adds `dubbo.application` to business Dubbo RPC spans from the resource's `service.name`. It also normalizes the Dubbo RPC service identity for metric correlation without changing the original `rpc.service`, resource identity, or unrelated HTTP, database, and internal spans. Grafana maps span attributes to labels on existing Dubbo Prometheus metrics: + +| Span attribute | Prometheus label | +| --- | --- | +| `dubbo.application` | `application_name` | +| `dubbo.metric.interface` | `interface` | +| `rpc.method` | `method` | + +Some dubbo-go versions, including v3.1.1 and v3.3.2, can emit `rpc.service` as the legacy ServiceKey `[group/]interface[:version]`, while the corresponding metric uses the plain `interface` label. For non-empty group/version, the Collector extracts `dubbo.metric.interface`, `dubbo.metric.group`, and `dubbo.metric.version`. When a span already has explicit `dubbo.group` or `dubbo.version`, those attributes take precedence over parsed ServiceKey values. Missing group/version attributes stay absent. Pure interface names, including Java RPC services, remain unchanged. The upstream dubbo-go #3724 change improves the source semantics; Collector normalization provides compatibility with versions that emit the legacy ServiceKey format, without requiring an SDK upgrade. + +The Collector requires `rpc.system=apache_dubbo` and a non-empty `rpc.service` before adding normalized metric attributes. It also requires non-null `rpc.method` and resource `service.name` before adding `dubbo.application`. The datasource provisioning declares the stable Prometheus UID `dubbo-admin-prometheus`, which the Grafana Jaeger datasource references. Group/version are retained as span attributes but are not yet mapped into Grafana's `$__tags` selector: static provisioning evidence does not establish whether an absent optional span attribute is omitted rather than emitted as an empty matcher. Consequently the current links correlate by application, interface, and method, not by group/version. No spanmetrics pipeline or new RPC metric is required. + +## V1 queries + +```promql +sum by (application_name, interface, method) ( + rate(dubbo_provider_requests_total{$__tags}[5m]) +) +``` + +```promql +sum by (application_name, interface, method) ( + rate(dubbo_consumer_requests_total{$__tags}[5m]) +) +``` + +Grafana substitutes `$__tags` with the three label matchers from the selected span. In the datasource provisioning file, write `$$__tags` so Grafana's provisioning interpolation leaves `$__tags` in the runtime datasource. Use `spanStartTimeShift: "-5m"` and `spanEndTimeShift: "5m"`; Grafana's interval parser does not accept a leading `+` for the end shift. + +## Verification scope and limits + +### Apply to an existing Kubernetes deployment + +Apply the Collector ConfigMap and Grafana datasource Secret in your chosen +cluster/context, then explicitly restart the two deployments. The Collector +configuration uses a `subPath` mount, which does not receive ConfigMap updates +in an existing container. Grafana reads datasource provisioning at startup; +updating the Secret alone does not reload it. These changes do not update +Deployment checksum annotations or trigger an automatic rollout. + +```sh +kubectl --context YOUR_CONTEXT apply -f release/kubernetes/monitoring/otelCollector-configuration.yaml +kubectl --context YOUR_CONTEXT apply -f release/kubernetes/monitoring/grafana-dashboardDatasources.yaml +kubectl --context YOUR_CONTEXT -n monitoring rollout restart deployment/otel-collector deployment/grafana +kubectl --context YOUR_CONTEXT -n monitoring rollout status deployment/otel-collector --timeout=120s +kubectl --context YOUR_CONTEXT -n monitoring rollout status deployment/grafana --timeout=120s +``` + +Use your existing Grafana connection and authentication to read +`GET /api/datasources/uid/fe670vj54q48wc`. Confirm `jsonData.tracesToMetrics` +contains the three mappings above, the two queries, Prometheus UID +`dubbo-admin-prometheus`, and shifts `-5m` / `5m`. In the API response the +query contains `$__tags`, not the provisioning escape `$$__tags`. Also check +`GET /api/datasources/uid/fe670vj54q48wc/health` and +`GET /api/datasources/uid/dubbo-admin-prometheus/health`. + +Emit a new RPC after rollout and read it from Jaeger. Confirm its span has +`dubbo.application` and the normalized `dubbo.metric.interface` alongside +unchanged `rpc.service` and `rpc.method`. Old stored traces are not enriched +retroactively. A Ready Pod or datasource health response alone does not prove +the new identity transform ran. Fresh installations load both configurations +on startup; the same API and new-span checks still apply. + +### Repeatable local configuration and transform checks + +The static check includes traces pipeline wiring, not only statement text: + +```powershell +pwsh -File scripts/verify-trace-metric-servicekey.ps1 +``` + +For actual OTTL execution, install Python 3 with PyYAML and supply a native +OTel Collector Contrib **0.112.0** binary: + +```sh +python scripts/verify-trace-metric-collector.py --collector /path/to/otelcol-contrib +``` + +This validates the release config without starting its cluster-dependent +pipeline, then copies the actual release transform into an isolated Collector +with a loopback OTLP HTTP receiver and temporary file exporter. It verifies +Java/plain interfaces, legacy ServiceKeys, explicit group/version precedence, +missing identity, and non-Dubbo spans against the exported attributes. It does +not reimplement OTTL in Python, connect to Kubernetes/Jaeger/Prometheus, or +perform browser verification. Missing prerequisites are a failed verification, +not a PASS. The test stops only its own Collector process and removes its own +temporary files. + +Mapping a legacy ServiceKey directly from `rpc.service` to the metric's plain `interface` label does not match when group/version are present; the normalized `dubbo.metric.interface` avoids that mismatch. The checks above cover static wiring, configuration validation and isolated execution of the normalization processor with Collector Contrib 0.112.0; they do not establish the full RPC → Jaeger → Grafana → Prometheus runtime or browser journey. Validate that end-to-end behavior with fresh RPCs in the target deployment. A selected RPC and time window may have no metric series when that side does not emit the corresponding counter, has insufficient Prometheus samples, or was not scraped. + +V1 intentionally excludes success rate, error rate, latency/percentiles, instance metrics, exemplars, service graphs, and automatic side selection. Trace-to-metric does not change the separate Log integration or add a Dubbo Admin backend API or frontend RPC selector. diff --git a/release/kubernetes/monitoring/grafana-dashboardDatasources.yaml b/release/kubernetes/monitoring/grafana-dashboardDatasources.yaml index 7b0dedf58..50acc23d3 100644 --- a/release/kubernetes/monitoring/grafana-dashboardDatasources.yaml +++ b/release/kubernetes/monitoring/grafana-dashboardDatasources.yaml @@ -34,6 +34,7 @@ stringData: "name": "prometheus", "orgId": 1, "type": "prometheus", + "uid": "dubbo-admin-prometheus", "url": "http://prometheus-k8s.monitoring.svc:9090", "version": 1 }, @@ -45,6 +46,22 @@ stringData: "type": "jaeger", "uid": "fe670vj54q48wc", "url": "http://jaeger.monitoring.svc:16686", + "jsonData": { + "tracesToMetrics": { + "datasourceUid": "dubbo-admin-prometheus", + "tags": [ + {"key": "dubbo.application", "value": "application_name"}, + {"key": "dubbo.metric.interface", "value": "interface"}, + {"key": "rpc.method", "value": "method"} + ], + "queries": [ + {"name": "Provider Request Rate", "query": "sum by (application_name, interface, method) (rate(dubbo_provider_requests_total{$$__tags}[5m]))"}, + {"name": "Consumer Request Rate", "query": "sum by (application_name, interface, method) (rate(dubbo_consumer_requests_total{$$__tags}[5m]))"} + ], + "spanStartTimeShift": "-5m", + "spanEndTimeShift": "5m" + } + }, "version": 1 } ] diff --git a/scripts/verify-trace-metric-servicekey.ps1 b/scripts/verify-trace-metric-servicekey.ps1 new file mode 100644 index 000000000..ca1c15a39 --- /dev/null +++ b/scripts/verify-trace-metric-servicekey.ps1 @@ -0,0 +1,109 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$collector = Get-Content -LiteralPath (Join-Path $root 'release/kubernetes/monitoring/otelCollector-configuration.yaml') -Raw +$datasource = Get-Content -LiteralPath (Join-Path $root 'release/kubernetes/monitoring/grafana-dashboardDatasources.yaml') -Raw + +# Guard the exact patterns used by the Collector. This is a static contract test; +# the Collector config is validated separately with otelcol-contrib. +$pipeline = [regex]::Match($collector, '(?ms)^ service:\s*\r?\n pipelines:\s*\r?\n traces:\s*\r?\n(?.*?)(?=^ \S|^ \S|\z)') +if (-not $pipeline.Success -or + -not [regex]::IsMatch($pipeline.Groups['body'].Value, '(?m)^ processors:\s*\[\s*k8sattributes\s*,\s*transform/dubbo_application\s*\]\s*$')) { + throw 'Trace pipeline must execute k8sattributes followed by transform/dubbo_application' +} +'PASS TRACE_PIPELINE_WIRING' +foreach ($statement in @( + 'set(attributes["dubbo.metric.interface"], attributes["rpc.service"])', + 'replace_pattern(attributes["dubbo.metric.interface"], "^[^/]+/", "")', + 'set(attributes["dubbo.metric.version"], attributes["dubbo.metric.interface"])', + 'replace_pattern(attributes["dubbo.metric.interface"], ":[^:]+$", "")', + 'replace_pattern(attributes["dubbo.metric.group"], "/.*$", "")', + 'replace_pattern(attributes["dubbo.metric.version"], "^.*:", "")', + 'set(attributes["dubbo.metric.group"], attributes["dubbo.group"])', + 'set(attributes["dubbo.metric.version"], attributes["dubbo.version"])' +)) { + if (-not $collector.Contains($statement)) { throw "Missing Collector statement: $statement" } +} +$stripGroup = $collector.IndexOf('replace_pattern(attributes["dubbo.metric.interface"], "^[^/]+/", "")') +$deriveVersion = $collector.IndexOf('set(attributes["dubbo.metric.version"], attributes["dubbo.metric.interface"])') +$stripVersion = $collector.IndexOf('replace_pattern(attributes["dubbo.metric.interface"], ":[^:]+$", "")') +if (-not ($stripGroup -lt $deriveVersion -and $deriveVersion -lt $stripVersion)) { + throw 'Collector must remove group before deriving version, then remove version from interface' +} +if (-not $datasource.Contains('{"key": "dubbo.metric.interface", "value": "interface"}')) { + throw 'Grafana interface mapping is not normalized' +} +if ($datasource.Contains('{"key": "rpc.service", "value": "interface"}')) { + throw 'Grafana still maps raw rpc.service to interface' +} +if ($datasource.Contains('{"key": "dubbo.metric.group", "value": "group"}') -or + $datasource.Contains('{"key": "dubbo.metric.version", "value": "version"}')) { + throw 'Optional group/version matcher mapping requires separate verification' +} +foreach ($line in ($collector -split "`r?`n" | Where-Object { $_ -match '^\s+- (set|replace_pattern)\(attributes\["dubbo\.metric\.' })) { + if (-not $line.Contains('attributes["rpc.system"] == "apache_dubbo"')) { + throw "Normalization statement lacks the Dubbo guard: $line" + } +} + +function Normalize-ForStaticTest([hashtable]$span) { + $result = $span.Clone() + $service = [string]$span['rpc.service'] + if ($span['rpc.system'] -cne 'apache_dubbo' -or [string]::IsNullOrEmpty($service)) { return $result } + $remainder = $service -creplace '^[^/]+/', '' + $result['dubbo.metric.interface'] = $remainder -creplace ':[^:]+$', '' + if ($span.ContainsKey('dubbo.group')) { + if (-not [string]::IsNullOrEmpty([string]$span['dubbo.group'])) { $result['dubbo.metric.group'] = $span['dubbo.group'] } + } elseif ($service -cmatch '^[^/]+/.+') { + $result['dubbo.metric.group'] = $service -creplace '/.*$', '' + } + if ($span.ContainsKey('dubbo.version')) { + if (-not [string]::IsNullOrEmpty([string]$span['dubbo.version'])) { $result['dubbo.metric.version'] = $span['dubbo.version'] } + } elseif ($remainder -cmatch ':[^:]+$') { + $result['dubbo.metric.version'] = $remainder -creplace '^.*:', '' + } + return $result +} + +$cases = @( + @{ Name='PURE_INTERFACE'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='org.example.EchoService' }; Interface='org.example.EchoService'; Group=$null; Version=$null }, + @{ Name='GROUP_ONLY'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='gray/org.example.EchoService' }; Interface='org.example.EchoService'; Group='gray'; Version=$null }, + @{ Name='VERSION_ONLY'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='org.example.EchoService:1.0.0' }; Interface='org.example.EchoService'; Group=$null; Version='1.0.0' }, + @{ Name='GROUP_AND_VERSION'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='gray/org.example.EchoService:1.0.0' }; Interface='org.example.EchoService'; Group='gray'; Version='1.0.0' }, + @{ Name='COLON_IN_GROUP_NO_VERSION'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='blue:canary/org.example.EchoService' }; Interface='org.example.EchoService'; Group='blue:canary'; Version=$null }, + @{ Name='COLON_IN_GROUP_WITH_VERSION'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='blue:canary/org.example.EchoService:1.0.0' }; Interface='org.example.EchoService'; Group='blue:canary'; Version='1.0.0' }, + @{ Name='POST_3724_EXPLICIT_ATTRIBUTES'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='org.example.EchoService'; 'dubbo.group'='gray'; 'dubbo.version'='1.0.0' }; Interface='org.example.EchoService'; Group='gray'; Version='1.0.0' }, + @{ Name='EXPLICIT_ATTRIBUTE_PRECEDENCE'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='gray/org.example.EchoService:1.0.0'; 'dubbo.group'='blue'; 'dubbo.version'='2.0.0' }; Interface='org.example.EchoService'; Group='blue'; Version='2.0.0' }, + @{ Name='JAVA_PURE_INTERFACE'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='org.apache.dubbo.samples.OrderService' }; Interface='org.apache.dubbo.samples.OrderService'; Group=$null; Version=$null } +) +foreach ($case in $cases) { + $actual = Normalize-ForStaticTest $case.Input + if ($actual['dubbo.metric.interface'] -cne $case.Interface -or + ($null -eq $case.Group -and $actual.ContainsKey('dubbo.metric.group')) -or + ($null -ne $case.Group -and $actual['dubbo.metric.group'] -cne $case.Group) -or + ($null -eq $case.Version -and $actual.ContainsKey('dubbo.metric.version')) -or + ($null -ne $case.Version -and $actual['dubbo.metric.version'] -cne $case.Version) -or + $actual['rpc.service'] -cne $case.Input['rpc.service']) { + throw "$($case.Name) failed: $($actual | ConvertTo-Json -Compress)" + } + "PASS $($case.Name)" +} +$nonDubbo = Normalize-ForStaticTest @{ 'rpc.system'='http'; 'rpc.service'='org.example.EchoService' } +if ($nonDubbo.ContainsKey('dubbo.metric.interface') -or $nonDubbo.ContainsKey('dubbo.metric.group') -or $nonDubbo.ContainsKey('dubbo.metric.version')) { + throw 'NON_DUBBO_UNCHANGED failed' +} +'PASS NON_DUBBO_UNCHANGED' From e57f82f692f1e43ef2259b6c60c88b303dc60dfa Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 28 Oct 2026 15:50:25 +0800 Subject: [PATCH 3/3] test(observability): run trace-to-metric contract checks in CI Execute the release OTTL transform with Collector Contrib 0.112.0 from the official container image in CI. Consolidate PowerShell verification into the Python entry point so fixtures and static contracts have a single owner. Retain error_mode: ignore in production and assert exported attributes in the runtime fixtures to catch silent normalization regressions. Document local prerequisites, CI coverage, and the remaining end-to-end validation boundary. --- .github/workflows/ci.yml | 22 +++++ docs/observability-trace-to-metric.md | 33 ++++++- scripts/verify-trace-metric-collector.py | 75 +++++++++++++-- scripts/verify-trace-metric-servicekey.ps1 | 106 +++------------------ 4 files changed, 136 insertions(+), 100 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aa4afc3c..9d3b919af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,28 @@ on: - develop jobs: + trace-to-metric-contract: + name: Trace to Metric Contract (Collector 0.112.0) + runs-on: ubuntu-latest + if: github.repository == 'apache/dubbo-admin' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install YAML parser + run: python -m pip install PyYAML==6.0.2 + - name: Extract the pinned Collector binary + env: + COLLECTOR_IMAGE: otel/opentelemetry-collector-contrib:0.112.0 + run: | + container=$(docker create "$COLLECTOR_IMAGE") + trap 'docker rm "$container"' EXIT + docker cp "$container:/otelcol-contrib" "$RUNNER_TEMP/otelcol-contrib" + chmod +x "$RUNNER_TEMP/otelcol-contrib" + - name: Check provisioning and execute release OTTL against fixtures + run: python scripts/verify-trace-metric-collector.py --collector "$RUNNER_TEMP/otelcol-contrib" + license-check: name: License Check runs-on: ubuntu-latest diff --git a/docs/observability-trace-to-metric.md b/docs/observability-trace-to-metric.md index 4ac4c2cce..69ef7efa5 100644 --- a/docs/observability-trace-to-metric.md +++ b/docs/observability-trace-to-metric.md @@ -70,19 +70,31 @@ on startup; the same API and new-span checks still apply. ### Repeatable local configuration and transform checks -The static check includes traces pipeline wiring, not only statement text: +Install Python 3 and PyYAML (`python -m pip install PyYAML`). The static +check includes traces pipeline wiring, statement ordering and Dubbo guards, +plus the Grafana datasource UID, tag mappings, queries and time shifts: + +```sh +python scripts/verify-trace-metric-collector.py --static-only +``` + +The PowerShell entry point delegates to the same Python checks: ```powershell pwsh -File scripts/verify-trace-metric-servicekey.ps1 ``` -For actual OTTL execution, install Python 3 with PyYAML and supply a native +For actual OTTL execution, supply a native OTel Collector Contrib **0.112.0** binary: ```sh python scripts/verify-trace-metric-collector.py --collector /path/to/otelcol-contrib ``` +PowerShell users can instead pass `-Collector /path/to/otelcol-contrib` to +`scripts/verify-trace-metric-servicekey.ps1`. Both entry points use the single +fixture matrix in Python; there is no separate PowerShell normalization model. + This validates the release config without starting its cluster-dependent pipeline, then copies the actual release transform into an isolated Collector with a loopback OTLP HTTP receiver and temporary file exporter. It verifies @@ -93,6 +105,23 @@ perform browser verification. Missing prerequisites are a failed verification, not a PASS. The test stops only its own Collector process and removes its own temporary files. +The `Trace to Metric Contract (Collector 0.112.0)` job in +`.github/workflows/ci.yml` runs the static checks and actual OTTL fixture test +for pull requests to `develop` and pushes to `develop`. It extracts the native +Linux binary from `otel/opentelemetry-collector-contrib:0.112.0` and runs it +with Python on the runner, so no cluster access or published receiver port is +needed. It also checks the Collector version before execution. Static-only +success is not a substitute for this runtime check. + +The production transform deliberately retains `error_mode: ignore`: one bad +span must not prevent the rest of a batch from reaching Jaeger. Configuration +validation alone cannot detect every expression that silently fails or writes +the wrong attribute. The CI test therefore asserts the actual exported +attributes and preservation of original attributes for every fixture, including +legacy ServiceKeys, explicit precedence, a colon in the group with and without +a version, missing identity, and non-Dubbo input. This is a regression guard for +the covered cases, not a claim that all possible malformed spans are covered. + Mapping a legacy ServiceKey directly from `rpc.service` to the metric's plain `interface` label does not match when group/version are present; the normalized `dubbo.metric.interface` avoids that mismatch. The checks above cover static wiring, configuration validation and isolated execution of the normalization processor with Collector Contrib 0.112.0; they do not establish the full RPC → Jaeger → Grafana → Prometheus runtime or browser journey. Validate that end-to-end behavior with fresh RPCs in the target deployment. A selected RPC and time window may have no metric series when that side does not emit the corresponding counter, has insufficient Prometheus samples, or was not scraped. V1 intentionally excludes success rate, error rate, latency/percentiles, instance metrics, exemplars, service graphs, and automatic side selection. Trace-to-metric does not change the separate Log integration or add a Dubbo Admin backend API or frontend RPC selector. diff --git a/scripts/verify-trace-metric-collector.py b/scripts/verify-trace-metric-collector.py index b82d56b55..02802df71 100644 --- a/scripts/verify-trace-metric-collector.py +++ b/scripts/verify-trace-metric-collector.py @@ -15,7 +15,7 @@ """Execute the release transform in Collector 0.112.0, without cluster access. -Requires Python 3, PyYAML and a native otelcol-contrib 0.112.0 executable. +Requires Python 3, PyYAML and, unless --static-only, otelcol-contrib 0.112.0. Only the isolated HTTP receiver on loopback and a temporary file exporter run. """ @@ -55,15 +55,66 @@ def read_spans(path): return spans -def verify(collector, manifest): - version = subprocess.check_output([collector, "--version"], text=True).strip() - if not version.endswith(" 0.112.0"): - raise RuntimeError(f"Expected Collector Contrib 0.112.0, got {version}") +def check_contract(manifest, datasource_manifest): config = yaml.safe_load(yaml.safe_load(manifest.read_text(encoding="utf-8"))["data"]["config.yaml"]) pipeline = config["service"]["pipelines"]["traces"]["processors"] if pipeline != ["k8sattributes", "transform/dubbo_application"]: raise RuntimeError("Trace pipeline must execute k8sattributes then transform/dubbo_application") transform = config["processors"]["transform/dubbo_application"] + statements = [statement for block in transform["trace_statements"] for statement in block["statements"]] + required = [ + 'set(attributes["dubbo.metric.interface"], attributes["rpc.service"])', + 'replace_pattern(attributes["dubbo.metric.interface"], "^[^/]+/", "")', + 'set(attributes["dubbo.metric.version"], attributes["dubbo.metric.interface"])', + 'replace_pattern(attributes["dubbo.metric.interface"], ":[^:]+$", "")', + 'replace_pattern(attributes["dubbo.metric.group"], "/.*$", "")', + 'replace_pattern(attributes["dubbo.metric.version"], "^.*:", "")', + 'set(attributes["dubbo.metric.group"], attributes["dubbo.group"])', + 'set(attributes["dubbo.metric.version"], attributes["dubbo.version"])', + ] + positions = {} + for operation in required: + positions[operation] = next((index for index, statement in enumerate(statements) + if statement.startswith(operation + " where ")), None) + if positions[operation] is None: + raise AssertionError(f"Missing Collector operation: {operation}") + if not positions[required[1]] < positions[required[2]] < positions[required[3]]: + raise AssertionError("Collector must strip group, derive version, then strip version") + for statement in statements: + if statement.startswith(('set(attributes["dubbo.metric.', 'replace_pattern(attributes["dubbo.metric.')): + if 'attributes["rpc.system"] == "apache_dubbo"' not in statement: + raise AssertionError(f"Normalization statement lacks the Dubbo guard: {statement}") + print("PASS TRACE_PIPELINE_AND_TRANSFORM_CONTRACT") + + datasources = yaml.safe_load(yaml.safe_load(datasource_manifest.read_text(encoding="utf-8")) + ["stringData"]["datasources.yaml"])["datasources"] + prometheus = next(source for source in datasources if source["type"] == "prometheus") + jaeger = next(source for source in datasources if source["type"] == "jaeger") + correlation = jaeger["jsonData"]["tracesToMetrics"] + if correlation["datasourceUid"] != prometheus["uid"]: + raise AssertionError("Trace links must target the provisioned Prometheus UID") + if correlation["tags"] != [ + {"key": "dubbo.application", "value": "application_name"}, + {"key": "dubbo.metric.interface", "value": "interface"}, + {"key": "rpc.method", "value": "method"}, + ]: + raise AssertionError("Trace links must map application, normalized interface and method only") + if correlation["queries"] != [ + {"name": "Provider Request Rate", "query": "sum by (application_name, interface, method) (rate(dubbo_provider_requests_total{$$__tags}[5m]))"}, + {"name": "Consumer Request Rate", "query": "sum by (application_name, interface, method) (rate(dubbo_consumer_requests_total{$$__tags}[5m]))"}, + ]: + raise AssertionError("Trace links must preserve both request-rate queries and provisioning escapes") + if correlation["spanStartTimeShift"] != "-5m" or correlation["spanEndTimeShift"] != "5m": + raise AssertionError("Trace links must use -5m / 5m interval shifts") + print("PASS GRAFANA_TRACES_TO_METRICS_CONTRACT") + return config + + +def verify(collector, config): + version = subprocess.check_output([collector, "--version"], text=True).strip() + if not version.endswith(" 0.112.0"): + raise RuntimeError(f"Expected Collector Contrib 0.112.0, got {version}") + transform = config["processors"]["transform/dubbo_application"] # Expected outputs are fixtures, not a second implementation of the transform. cases = [ @@ -71,6 +122,7 @@ def verify(collector, manifest): ("GROUP_ONLY", "gray/org.example.EchoService", {}, "org.example.EchoService", "gray", None), ("VERSION_ONLY", "org.example.EchoService:1.0.0", {}, "org.example.EchoService", None, "1.0.0"), ("GROUP_AND_VERSION", "gray/org.example.EchoService:1.0.0", {}, "org.example.EchoService", "gray", "1.0.0"), + ("COLON_IN_GROUP_NO_VERSION", "blue:canary/org.example.EchoService", {}, "org.example.EchoService", "blue:canary", None), ("COLON_IN_GROUP", "blue:canary/org.example.EchoService:1.0.0", {}, "org.example.EchoService", "blue:canary", "1.0.0"), ("EXPLICIT_ATTRIBUTES", "org.example.EchoService", {"dubbo.group": "gray", "dubbo.version": "1.0.0"}, "org.example.EchoService", "gray", "1.0.0"), ("EXPLICIT_PRECEDENCE", "gray/org.example.EchoService:1.0.0", {"dubbo.group": "blue", "dubbo.version": "2.0.0"}, "org.example.EchoService", "blue", "2.0.0"), @@ -171,8 +223,17 @@ def verify(collector, manifest): if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--collector", required=True, help="Native otelcol-contrib 0.112.0 executable") + parser.add_argument("--collector", help="Native otelcol-contrib 0.112.0 executable") + parser.add_argument("--static-only", action="store_true", help="Check wiring without executing OTTL") parser.add_argument("--manifest", type=Path, default=Path(__file__).resolve().parents[1] / "release/kubernetes/monitoring/otelCollector-configuration.yaml") + parser.add_argument("--datasource-manifest", type=Path, default=Path(__file__).resolve().parents[1] / + "release/kubernetes/monitoring/grafana-dashboardDatasources.yaml") options = parser.parse_args() - verify(options.collector, options.manifest) + if not options.static_only and not options.collector: + parser.error("--collector is required unless --static-only is specified") + if options.static_only and options.collector: + parser.error("--static-only cannot be combined with --collector") + release = check_contract(options.manifest, options.datasource_manifest) + if not options.static_only: + verify(options.collector, release) diff --git a/scripts/verify-trace-metric-servicekey.ps1 b/scripts/verify-trace-metric-servicekey.ps1 index ca1c15a39..1e4c8ed3d 100644 --- a/scripts/verify-trace-metric-servicekey.ps1 +++ b/scripts/verify-trace-metric-servicekey.ps1 @@ -13,97 +13,21 @@ # See the License for the specific language governing permissions and # limitations under the License. -$ErrorActionPreference = 'Stop' -$root = Split-Path $PSScriptRoot -Parent -$collector = Get-Content -LiteralPath (Join-Path $root 'release/kubernetes/monitoring/otelCollector-configuration.yaml') -Raw -$datasource = Get-Content -LiteralPath (Join-Path $root 'release/kubernetes/monitoring/grafana-dashboardDatasources.yaml') -Raw - -# Guard the exact patterns used by the Collector. This is a static contract test; -# the Collector config is validated separately with otelcol-contrib. -$pipeline = [regex]::Match($collector, '(?ms)^ service:\s*\r?\n pipelines:\s*\r?\n traces:\s*\r?\n(?.*?)(?=^ \S|^ \S|\z)') -if (-not $pipeline.Success -or - -not [regex]::IsMatch($pipeline.Groups['body'].Value, '(?m)^ processors:\s*\[\s*k8sattributes\s*,\s*transform/dubbo_application\s*\]\s*$')) { - throw 'Trace pipeline must execute k8sattributes followed by transform/dubbo_application' -} -'PASS TRACE_PIPELINE_WIRING' -foreach ($statement in @( - 'set(attributes["dubbo.metric.interface"], attributes["rpc.service"])', - 'replace_pattern(attributes["dubbo.metric.interface"], "^[^/]+/", "")', - 'set(attributes["dubbo.metric.version"], attributes["dubbo.metric.interface"])', - 'replace_pattern(attributes["dubbo.metric.interface"], ":[^:]+$", "")', - 'replace_pattern(attributes["dubbo.metric.group"], "/.*$", "")', - 'replace_pattern(attributes["dubbo.metric.version"], "^.*:", "")', - 'set(attributes["dubbo.metric.group"], attributes["dubbo.group"])', - 'set(attributes["dubbo.metric.version"], attributes["dubbo.version"])' -)) { - if (-not $collector.Contains($statement)) { throw "Missing Collector statement: $statement" } -} -$stripGroup = $collector.IndexOf('replace_pattern(attributes["dubbo.metric.interface"], "^[^/]+/", "")') -$deriveVersion = $collector.IndexOf('set(attributes["dubbo.metric.version"], attributes["dubbo.metric.interface"])') -$stripVersion = $collector.IndexOf('replace_pattern(attributes["dubbo.metric.interface"], ":[^:]+$", "")') -if (-not ($stripGroup -lt $deriveVersion -and $deriveVersion -lt $stripVersion)) { - throw 'Collector must remove group before deriving version, then remove version from interface' -} -if (-not $datasource.Contains('{"key": "dubbo.metric.interface", "value": "interface"}')) { - throw 'Grafana interface mapping is not normalized' -} -if ($datasource.Contains('{"key": "rpc.service", "value": "interface"}')) { - throw 'Grafana still maps raw rpc.service to interface' -} -if ($datasource.Contains('{"key": "dubbo.metric.group", "value": "group"}') -or - $datasource.Contains('{"key": "dubbo.metric.version", "value": "version"}')) { - throw 'Optional group/version matcher mapping requires separate verification' -} -foreach ($line in ($collector -split "`r?`n" | Where-Object { $_ -match '^\s+- (set|replace_pattern)\(attributes\["dubbo\.metric\.' })) { - if (-not $line.Contains('attributes["rpc.system"] == "apache_dubbo"')) { - throw "Normalization statement lacks the Dubbo guard: $line" - } -} - -function Normalize-ForStaticTest([hashtable]$span) { - $result = $span.Clone() - $service = [string]$span['rpc.service'] - if ($span['rpc.system'] -cne 'apache_dubbo' -or [string]::IsNullOrEmpty($service)) { return $result } - $remainder = $service -creplace '^[^/]+/', '' - $result['dubbo.metric.interface'] = $remainder -creplace ':[^:]+$', '' - if ($span.ContainsKey('dubbo.group')) { - if (-not [string]::IsNullOrEmpty([string]$span['dubbo.group'])) { $result['dubbo.metric.group'] = $span['dubbo.group'] } - } elseif ($service -cmatch '^[^/]+/.+') { - $result['dubbo.metric.group'] = $service -creplace '/.*$', '' - } - if ($span.ContainsKey('dubbo.version')) { - if (-not [string]::IsNullOrEmpty([string]$span['dubbo.version'])) { $result['dubbo.metric.version'] = $span['dubbo.version'] } - } elseif ($remainder -cmatch ':[^:]+$') { - $result['dubbo.metric.version'] = $remainder -creplace '^.*:', '' - } - return $result -} - -$cases = @( - @{ Name='PURE_INTERFACE'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='org.example.EchoService' }; Interface='org.example.EchoService'; Group=$null; Version=$null }, - @{ Name='GROUP_ONLY'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='gray/org.example.EchoService' }; Interface='org.example.EchoService'; Group='gray'; Version=$null }, - @{ Name='VERSION_ONLY'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='org.example.EchoService:1.0.0' }; Interface='org.example.EchoService'; Group=$null; Version='1.0.0' }, - @{ Name='GROUP_AND_VERSION'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='gray/org.example.EchoService:1.0.0' }; Interface='org.example.EchoService'; Group='gray'; Version='1.0.0' }, - @{ Name='COLON_IN_GROUP_NO_VERSION'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='blue:canary/org.example.EchoService' }; Interface='org.example.EchoService'; Group='blue:canary'; Version=$null }, - @{ Name='COLON_IN_GROUP_WITH_VERSION'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='blue:canary/org.example.EchoService:1.0.0' }; Interface='org.example.EchoService'; Group='blue:canary'; Version='1.0.0' }, - @{ Name='POST_3724_EXPLICIT_ATTRIBUTES'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='org.example.EchoService'; 'dubbo.group'='gray'; 'dubbo.version'='1.0.0' }; Interface='org.example.EchoService'; Group='gray'; Version='1.0.0' }, - @{ Name='EXPLICIT_ATTRIBUTE_PRECEDENCE'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='gray/org.example.EchoService:1.0.0'; 'dubbo.group'='blue'; 'dubbo.version'='2.0.0' }; Interface='org.example.EchoService'; Group='blue'; Version='2.0.0' }, - @{ Name='JAVA_PURE_INTERFACE'; Input=@{ 'rpc.system'='apache_dubbo'; 'rpc.service'='org.apache.dubbo.samples.OrderService' }; Interface='org.apache.dubbo.samples.OrderService'; Group=$null; Version=$null } +# Keep this entry point for PowerShell users; Python owns all contract checks +# and the single fixture matrix executed by the real Collector. +param( + [string]$Python = 'python', + [string]$Collector ) -foreach ($case in $cases) { - $actual = Normalize-ForStaticTest $case.Input - if ($actual['dubbo.metric.interface'] -cne $case.Interface -or - ($null -eq $case.Group -and $actual.ContainsKey('dubbo.metric.group')) -or - ($null -ne $case.Group -and $actual['dubbo.metric.group'] -cne $case.Group) -or - ($null -eq $case.Version -and $actual.ContainsKey('dubbo.metric.version')) -or - ($null -ne $case.Version -and $actual['dubbo.metric.version'] -cne $case.Version) -or - $actual['rpc.service'] -cne $case.Input['rpc.service']) { - throw "$($case.Name) failed: $($actual | ConvertTo-Json -Compress)" - } - "PASS $($case.Name)" + +$ErrorActionPreference = 'Stop' +$arguments = @((Join-Path $PSScriptRoot 'verify-trace-metric-collector.py')) +if ($Collector) { + $arguments += @('--collector', $Collector) +} else { + $arguments += '--static-only' } -$nonDubbo = Normalize-ForStaticTest @{ 'rpc.system'='http'; 'rpc.service'='org.example.EchoService' } -if ($nonDubbo.ContainsKey('dubbo.metric.interface') -or $nonDubbo.ContainsKey('dubbo.metric.group') -or $nonDubbo.ContainsKey('dubbo.metric.version')) { - throw 'NON_DUBBO_UNCHANGED failed' +& $Python @arguments +if ($LASTEXITCODE -ne 0) { + throw "Trace-to-metric verification failed (exit $LASTEXITCODE)" } -'PASS NON_DUBBO_UNCHANGED'