Skip to content

Group OTLP services from a resource attribute, and keep a service's own topology node - #14153

Merged
wankai123 merged 8 commits into
masterfrom
feature/otlp-service-group
Oct 22, 2026
Merged

wankai123 merged 8 commits into
masterfrom
feature/otlp-service-group

Conversation

@wankai123

@wankai123 wankai123 commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Fix the service topology drawing a service as another one whose network address alias shares its name

  • Add a unit test to verify that the fix works.
  • Explain briefly why the bug exists and how to fix it.

On the showcase, the OpenTelemetry Astronomy Shop's frontend-proxy was drawn calling the music application's agent::frontend, though the stored relation was frontend-proxy -> frontend. ServiceTopologyBuilder renames a client call's destination through the network address alias cache, keyed by the destination's name, to fold calls an agent recorded before an address's alias existed. It did so for a real service too: an agent had reached agent::frontend at the bare host frontend, so the alias frontend -> agent::frontend renamed the OTLP service frontend.

  • ServiceTopologyBuilder now folds only conjectured, address-named destinations. The agent path stores an unresolved exit-span peer as conjectured, so its folding is unchanged; ALS resolves both ends to real services and never needed it.
  • TAL stored an address it could not resolve as a real OTLP_SERVICE node, which folded only through that renaming. The shipped rule now draws it as a conjectured UNDEFINED node, as the agent path does, so those first calls still fold once the alias lands. The new TAL function aliasOf(address) returns the resolved service, or empty when nothing names the address; resolveAlias keeps its contract on top of it.

Group OTLP services from a resource attribute

  • If this is non-trivial feature, paste the links/URLs to the design doc.
  • Update the documentation to include this new feature.
  • Tests(including UT, IT, E2E) are added to verify the new feature.
  • If it's UI related, attach the screenshots below.

receiver-otel.otlpServiceGroupKeys (SW_OTEL_RECEIVER_OTLP_SERVICE_GROUP, empty by default) lists resource attribute keys in order of preference. The first one a resource carries groups its service in OAP's group::name convention: cart with service.namespace=opentelemetry-demo is opentelemetry-demo::cart, in the trace analysis, the stored spans and the OTLP logs. The resource attributes stay as sent. A service.name already grouped is kept, and a value holding :: groups nothing.

  • Grouping service.name from the SDK or the collector breaks TAL's peer naming, which matches a host a caller dials against the known service names. KnownServices now also knows a grouped service by its short name, so cart:7070 still reaches opentelemetry-demo::cart and the proxy guard still holds; a short name two groups share matches neither.
  • TraceQL searched the grouped service_name column, but OTLPSpanMatcher re-derived each span's service from its resource and dropped every span of a grouped service. SpanWrapper now carries the service the span was stored under (service = 3); the matcher, the search's root and service names, and the tag values read it, and a span stored without it is resolved from the resource as before.

Verification

  • Unit tests: ServiceTopologyBuilderTest (a real destination named like an alias address keeps its node; a conjectured address still folds), TALExecutionTest (aliasOf), KnownServicesTest, AliasRegistrarTest, OTLPServiceGroupTest, OTLPServiceGroupHandlerTest (the analysis and the store see the grouped name; service.name stays as sent), OTLPSpanRecordWrapperTest, OTLPSpanMatcherTest, OTLPTraceAssemblerTest. ShippedRuleOverRecordedTraceTest now expects the cold pass's two address-dialed peers as conjectured. server-core 368, trace-analyzer 164, traceql-plugin 63 and otel-receiver-plugin 35 tests pass.

  • On a kind cluster running the showcase (BanyanDB, the SkyWalking agents and the Astronomy Shop) with SW_OTEL_RECEIVER_OTLP_SERVICE_GROUP=service.namespace,k8s.namespace.name:

    • the shop's services are opentelemetry-demo::* with group opentelemetry-demo; frontend-web and image-provider, which carry no service.namespace, stay ungrouped;
    • opentelemetry-demo::frontend-proxy -> opentelemetry-demo::frontend lands on the shop's own frontend, not agent::frontend; frontend -> cart, checkout -> payment and the other host-dialed calls land on the grouped services, and frontend-web -> opentelemetry-demo::frontend-proxy keeps the proxy hop;
    • {resource.service.name="opentelemetry-demo::cart"} and opentelemetry-demo::frontend find traces, with grouped root names; {resource.service.namespace="opentelemetry-demo"} still matches.
  • If this pull request closes/resolves/fixes an existing issue, replace the issue number. Closes #.

  • Update the CHANGES log.

…wn topology node

The topology query renamed a client call's destination through the network address alias cache, keyed by
the destination's name, for real services too: an agent that reached `agent::frontend` at the host
`frontend` turned the OTLP service `frontend` into `agent::frontend`. It now folds only conjectured,
address-named destinations, and the shipped TAL rule draws a peer it could not resolve as a conjectured
`UNDEFINED` node, as the agent path does, through the new `aliasOf(address)` function.

`receiver-otel.otlpServiceGroupKeys` (SW_OTEL_RECEIVER_OTLP_SERVICE_GROUP) groups an OTLP service under
the first listed resource attribute its resource carries, `cart` with `service.namespace=shop` being
`shop::cart`, in the trace analysis, the stored spans and the OTLP logs, while the resource attributes
stay as sent. The trace analyzer knows a grouped service by its short name, so the host a caller dials
still names it and the proxy guard holds. SpanWrapper carries the service a span was stored under, so a
TraceQL search on a grouped service matches its spans instead of re-deriving the service from the
resource.
…e-group

# Conflicts:
#	docs/en/changes/changes.md

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Short-name cache expiration and concurrent registration can break grouped-service resolution.

2 open issues
What changed in this PR

Adds resource-based OTLP service grouping across analysis, storage, queries, and logs, while preventing address aliases from renaming real topology nodes.

Changes:

  • Adds configurable service grouping and short-name peer resolution.
  • Preserves stored service names in TraceQL and limits alias folding to conjectured nodes.
  • Adds regression tests, configuration defaults, and operator documentation.
File Description
test/​e2e-v2/​cases/​storage/​expected/​config-dump.yml Adds the grouping default.
oap-server/​server-storage-plugin/​storage-jdbc-hikaricp-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​storage/​plugin/​jdbc/​common/​dao/​JDBCOTLPTraceQueryDAO.java Loads stored service names.
oap-server/​server-starter/​src/​main/​resources/​tal/​otlp-default.yaml Marks unresolved address peers as conjectured.
oap-server/​server-starter/​src/​main/​resources/​application.yml Exposes service-group configuration.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​ShippedRuleOverRecordedTraceTest.java Updates cold-pass topology expectations.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​OTLPServiceGroupTest.java Tests grouping rules and fallbacks.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​OTLPServiceGroupHandlerTest.java Tests grouped analysis and persistence.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​OTLPServiceGroup.java Implements resource-based grouping.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​OpenTelemetryTraceHandler.java Applies grouping to trace processing.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​OpenTelemetryLogHandler.java Applies grouping to logs.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​OtelMetricReceiverConfig.java Parses grouping attribute keys.
oap-server/​server-query-plugin/​traceql-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​query/​traceql/​converter/​OTLPTraceAssemblerTest.java Tests grouped search results.
oap-server/​server-query-plugin/​traceql-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​query/​traceql/​converter/​OTLPSpanMatcherTest.java Tests stored-service matching and fallback.
oap-server/​server-query-plugin/​traceql-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​query/​traceql/​handler/​OTLPTraceQLApiHandler.java Passes stored services into query processing.
oap-server/​server-query-plugin/​traceql-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​query/​traceql/​converter/​OTLPTraceAssembler.java Uses stored services in results and tags.
oap-server/​server-query-plugin/​traceql-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​query/​traceql/​converter/​OTLPSpanMatcher.java Prefers stored service names when matching.
oap-server/​server-core/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​core/​query/​ServiceTopologyBuilderTest.java Tests real-node preservation and alias folding.
oap-server/​server-core/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​core/​otlp/​OTLPSpanRecordWrapperTest.java Tests service propagation into wrappers.
oap-server/​server-core/​src/​main/​proto/​SpanWrapper.proto Adds stored-service metadata.
oap-server/​server-core/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​core/​query/​ServiceTopologyBuilder.java Restricts folding to conjectured destinations.
oap-server/​server-core/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​core/​otlp/​OTLPSpanRecord.java Includes service names in wrappers.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​span/​KnownServicesTest.java Tests grouped and ambiguous short names.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​span/​AliasRegistrarTest.java Tests grouped host matching and proxy protection.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​dsl/​TALExecutionTest.java Tests resolved and unresolved aliases.
oap-server/​analyzer/​trace-analyzer/​src/​main/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​span/​KnownServices.java Adds short-name service mappings.
oap-server/​analyzer/​trace-analyzer/​src/​main/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​span/​AliasRegistrar.java Resolves hosts through short-name mappings.
oap-server/​analyzer/​trace-analyzer/​src/​main/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​compiler/​TALSpanRootResolver.java Registers the aliasOf function.
oap-server/​analyzer/​trace-analyzer/​src/​main/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​compiler/​rt/​TalRuntimeHelper.java Implements aliasOf, preserving resolveAlias behavior.
docs/​en/​setup/​backend/​trace-analyzer.md Explains conjectured-node folding.
docs/​en/​setup/​backend/​otlp-trace.md Documents grouping behavior and configuration.
docs/​en/​setup/​backend/​configuration-vocabulary.md Lists the grouping setting.
docs/​en/​concepts-and-designs/​tal.md Documents aliasOf and unresolved peers.
docs/​en/​changes/​changes.md Records grouping and topology changes.

Review effort: Balanced

…ared short names ambiguous under races

KnownServices.record returned early for a service it already knew, so the short-name entry was only kept alive by lookups: a grouped service that kept emitting spans could lose its short name after a day without lookups, and never get it back. The short-name entry is now read on every span and put back when it expired or was evicted.

Two groups' first spans racing on one short name could each overwrite the other's entry instead of marking it ambiguous. The update now uses the cache's atomic merge.

@wu-sheng wu-sheng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked at d683bf8.

The two KnownServices threads from Copilot are fixed: the short name is read on every span, and asMap().merge is atomic (Guava 32.0.1's LocalCache.merge runs compute under the segment lock). KnownServicesTest (8) and AliasRegistrarTest (5) pass locally.

The inline comments cover what is still open. Two minor notes:

  • Zipkin storage mode: Zipkin's Endpoint lowercases the service name. That already applied to service.name before this PR, but a group value with upper case (service.namespace=Shop) now gives Shop::cart in the analysis and the logs and shop::cart in the stored span. Kubernetes namespace names are always lower case, so only attributes such as service.namespace are affected; worth a line in the doc.
  • The checklist marks E2E as added, but there is no e2e case for otlpServiceGroupKeys; only config-dump.yml gained the default.

return address == null ? "" : address;
return "";
}
final String named = spec.getAliasRegistrar().serviceNamedBy(AliasRegistrar.hostOf(address));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serviceNamedBy answers before the alias cache, and with grouping it can now give a wrong service, not only a late one. KnownServices is per OAP node. If node A has only received outlet::cart spans (an SDK or collector stays on one node through its long-lived gRPC connection), cart is unique on A and resolves to outlet::cart. A shop::frontend span dialing cart:7070, or any caller dialing cart.shop.svc.cluster.local:7070, then lands on outlet::cart on that node, even when the alias cache holds the right row for the full address. The KnownServices javadoc says a node that never received a service's spans "only delays the guard"; with short names it gives a different answer.

Suggestion: trust a short-name hit only in the caller's own group (<caller group>::cart, which is also how a Kubernetes short name resolves, inside the caller's namespace), map cart.outlet[.svc.cluster.local] to outlet::cart by its second label, and ask the address-specific alias before a cross-group short-name hit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 57a1b9d. AliasRegistrar.serviceNamedBy now takes the caller and resolves as Kubernetes DNS does: a host qualified with a group names that group's service (cart.outlet[.svc.cluster.local] → outlet::cart), and a bare host the service of that name in the caller's own group (cart from shop::frontend → shop::cart). Another group's service of the same short name is now only a guess, asked after the alias row and the pairing, and never made from a qualified host's first label. To shorten the cold window after a restart, each node also records the stored OTLP_SERVICE services at start (for a day after the read), so a callee that rarely emits, such as flagd, is not named through another group's address row. Verified on kind with the Astronomy Shop in two namespaces grouped by k8s.namespace.name: no cross-group edges, including across an OAP restart.

Comment thread docs/en/setup/backend/otlp-trace.md Outdated
`{resource.service.namespace="opentelemetry-demo"}` still finds the group's traces. A caller that dials the host
`cart` still reaches `opentelemetry-demo::cart`, since the trace analyzer matches a host against a grouped service's
short name; a short name two groups share, `shop::cart` and `outlet::cart`, matches neither, and those calls are
named through the alias rows and the pairing instead.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't hold for the main case grouping is for: one app deployed in two namespaces. shop::frontend and outlet::frontend both dial cart:7070 (the OTel demo's CART_ADDR). nameOf("cart") is ambiguous and returns null, so aliasOf falls through to the alias cache. Both groups' SERVER spans register the same address cart:7070, because the AliasRegistrar.register guard doesn't fire when serviceNamedBy returns null, so the last write wins and the row flips as both keep registering. SpanPairing.learned is keyed by the address alone too (SpanPairing.java:122). Both namespaces' calls end up on one cart.

The agent path has the same address-keyed limitation, so this could be documented as a limitation instead, but then the doc should say these calls may land on either group. The caller-group preference suggested on aliasOf would fix it for Kubernetes short names.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 57a1b9d with the caller-group preference from the aliasOf thread, so both namespaces' frontend → cart:7070 land on their own cart. The doc now lists the resolution order and states the limits plainly: calls from an ungrouped caller (e.g. a browser app without the attribute), or to a Kubernetes Service named differently from service.name, still land on the address row, which is keyed by the address alone and may name either group, as on the agent path. It also recommends k8s.namespace.name first on Kubernetes.

final String seen = byShortName.getIfPresent(shortName);
if (seen == null || !(seen.equals(name) || AMBIGUOUS.equals(seen))) {
// Atomic, so two groups' first spans racing on one short name still leave it ambiguous.
byShortName.asMap().merge(shortName, name, (earlier, current) -> earlier.equals(current) ? earlier : AMBIGUOUS);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once a short name is AMBIGUOUS, it never clears while either service still emits. The getIfPresent above refreshes it on every span of the remaining service, and nothing checks whether the other group is still alive. After outlet::cart is retired, it expires from names a day later, but nameOf("cart") stays null for shop::cart until the OAP restarts, so that service loses short-name matching and the proxy guard for good. It fails safe (no wrong answer), but it never recovers.

Keeping each short name's members with their last-seen time, and treating it as ambiguous only while two of them were seen within the expiry window, would let it recover.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 57a1b9d. KnownServices now keeps each short name's grouped services with the time each last emitted, and a short name is ambiguous only while two of them emitted within a day; once one group falls silent, the short name names the other again (aSharedShortNameNamesTheRemainingGroupOnceTheOtherFallsSilent).

* The service name the receiver stored: the one the wrapper carries, which a service group may have
* changed, else resolved from the same resource keys in the same order.
*/
String serviceName() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serviceName() now returns the stored (grouped) name, but the search response's per-span service.name attribute doesn't use it. toSearchTrace projects the resource's own service.name whenever the resource carries one (line 448), which every SDK does, so only rootServiceName and serviceStats are grouped. In Zipkin mode, ZipkinOTLPConverter sets the grouped localServiceName and then overwrites it with the service.name tag the conversion keeps (lines 512-519).

Horizon's BFF reads the span's service.name and compares it with the queried service (apps/bff/src/client/traceql.ts:416,433), so a search on opentelemetry-demo::cart never finds the focused service's entry span. Projecting stored.serviceName() for the fixed service.name key would make the response consistent. Trace-by-id has the same gap: Horizon reads the service from the resource (traceql.ts:497), so two groups' cart look the same in the trace detail.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 57a1b9d. Search now lists the stored service as each span's service.name in both modes (OTLP: always stored.serviceName(); Zipkin: the local service is put after the tags so the kept service.name tag no longer overwrites it). Trace-by-id now sets each resource's service.name to the stored service in place (decodeAsStored), the other attributes as sent, so two groups' cart are distinct in the trace detail.

}
// The service the analysis names and the store files the span under; the resource attributes,
// service.name among them, stay as sent.
final String groupedServiceName = OTLPServiceGroup.apply(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The grouped name doesn't reach the span listeners. notifyOTLPPhase (line 233) and notifyNativeOTLPPhase (line 676) get only the resource attributes, and the listeners resolve the service themselves. AIEvaluationSpanListener.resolveServiceName (AIEvaluationSpanListener.java:134) resolves the name "so the evaluation record joins back to the trace's service", but with grouping on, its records name cart while the span is stored under shop::cart. A GenAI app in Kubernetes behind a collector with the k8sattributes processor is enough to hit this. The iOS listeners (IOSHTTPSpanListener.java:112, IOSMetricKitSpanListener.java:107) do the same, though only an app that sets the chosen attribute is affected.

Passing the resolved service to the listener phases, as an extra parameter on notifyOTLPPhase / notifyNativeOTLPPhase, would keep them consistent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 57a1b9d. SpanListener.onOTLPSpan / onNativeOTLPSpan and SpanListenerManager.notifyOTLPPhase / notifyNativeOTLPPhase take the grouped service as a new serviceName parameter. AI evaluation names it (through NamingControl, so the record joins the stored span), and the iOS listeners use it too. This changes the SPI; changes.md says an implementation overriding the old four-argument methods must move to the new signature.

@@ -188,6 +212,20 @@ private static List<StoredSpan> matched(final List<List<ResourceSpans>> traces,
* Decode stored wrappers. A wrapper of another {@link Source} is skipped: the store holds OTLP only, so one

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: storedServices was inserted between decode's javadoc and decode, so this block now sits above storedServices, next to its own javadoc, and decode (line 229) has no javadoc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 57a1b9d: the javadoc is back on decode, and storedServices has its own.

@wu-sheng wu-sheng added this to the 11.1.0 milestone Oct 19, 2026
@wu-sheng wu-sheng added backend OAP backend related. enhancement Enhancement on performance or codes labels Oct 19, 2026
…d service in TraceQL and the span listeners

Peer resolution under service grouping (review points 1-3):
- A bare host resolves in the caller's group, as Kubernetes DNS does (`cart` from `shop::frontend` is `shop::cart`); a host qualified with a group names that group's service (`cart.outlet[.svc.cluster.local]` is `outlet::cart`). Another group's service of the same short name is only a guess, ranked after the alias row and the span pairing, and never made from a qualified host's first label.
- A short name is ambiguous only while two groups' services emitted under it within a day, so it names the remaining one once the other falls silent.
- The proxy guard refuses an address whose first DNS label, or whole host, is any group's short name other than the registering service's own.
- Each node records the stored OTLP_SERVICE services at start, for a day after the read, so a restart does not forget a callee that emits only now and then.

TraceQL (point 4): search lists the stored service as each span's service.name in both storage modes, and trace-by-id carries it as the resource's service.name.

Span listeners (point 5): SpanListener.onOTLPSpan / onNativeOTLPSpan and the manager's phases take the grouped service; the AI evaluation and iOS listeners name it.

Docs: the resolution order and its limits, the guard rule, Zipkin's lowercasing, and the SpanListener SPI change.
@wankai123

Copy link
Copy Markdown
Member Author

Pushed 57a1b9d for the review. On the two notes in the summary:

  • Zipkin lowercasing: otlp-trace.md now says the Zipkin mode stores the service lower case, so a group value with capitals (service.namespace=Shop) gives Shop::cart in the analysis and the logs but shop::cart in the stored span and the query; Kubernetes namespace names are always lower case.
  • E2E: you're right, there is no e2e case for otlpServiceGroupKeys; I unticked the box. It was verified on a local kind cluster instead (two Astronomy Shop installs in otel-demo / otel-demo-2, grouped by k8s.namespace.name): grouped services, no cross-group edges, grouped names in TraceQL search and trace-by-id.

…e-group

# Conflicts:
#	docs/en/changes/changes.md
#	oap-server/analyzer/ai-evaluation/src/main/java/org/apache/skywalking/oap/server/ai/evaluation/listener/AIEvaluationSpanListener.java
@wankai123

Copy link
Copy Markdown
Member Author

How a service relation is built for OTLP traces (after this PR)

1. A CLIENT span picks its peer

The shipped rule (otlp-default.yaml) takes the first one present:

  1. service.peer.name / peer.service: a service name the span declares, used as the peer directly.
  2. server.address + server.port (net.peer.* on older SDKs)
  3. peer.address (Envoy)
  4. the host and port of url.full / http.url

Options 2–4 give an address (cart:7070). An address listed in gateways.yml becomes the VIRTUAL_GATEWAY node; any other goes to aliasOf.

2. aliasOf(address) turns the address into a service

First hit wins:

# Step Example
1 Qualified host: the group named by the second DNS label cart.outlet.svc… → outlet::cart
2 A service of the caller's group named exactly the host cart from shop::frontend → shop::cart
3 An ungrouped service named by the whole host or its first label product-catalog:3550 → product-catalog
4 The alias row for the exact address, written by the callee 10.0.0.7:8080 → songs
5 Pairing: an earlier CLIENT/SERVER pair matched by trace context
6 Guess: the one grouped service, in any group, whose short name is the whole host cart → the only x::cart

Steps 1–3 and 6 match only known services: ones that sent spans to this OAP node, or were read from storage at startup, within the last day. The guess never works from a qualified host's first label.

3. The edge is drawn

  • A step answers: caller → resolved service, as a real node.
  • No step answers: caller → address, as a conjectured UNDEFINED node. At query time, ServiceTopologyBuilder folds it into the service once an alias row for that address exists.

ServiceTopologyBuilder folds only conjectured nodes, so a real service whose name happens to match some alias address keeps its own node (this PR's original fix: frontend vs agent::frontend).

4. A SERVER span registers the alias row

alias <server.address:server.port> writes address → this service, unless a guard refuses it:

  • The host names another known service. A proxy that keeps the Host header: frontend reporting frontend-proxy:8080.
  • The host's first label, or the whole host, is any group's short name other than the callee's own. Covers a proxy in another group, a shared proxy name, and qualified proxy hosts.
  • The address is in gateways.yml.

Otherwise the last write wins, as on the agent path. Pairing writes rows through the same guards.

5. Limits

  • An ungrouped caller dialing a short name two groups share (e.g. a browser app without the grouping attribute) falls through to the alias row, which is keyed by address alone, so the call can land on either group.
  • A Kubernetes Service named differently from service.name can't be matched by steps 1–2.
  • A node that hasn't seen a callee yet uses the alias row, then pairing, then the guess; the startup load shortens that window after a restart.
  • External-hostname side effect: a host whose first label is a known short name (api.example.com while shop::api exists) is refused by the guard, as an ungrouped api already was before this PR.

Operator docs with the full text: otlp-trace.md (the six steps and limits), tal.md → Address-named peers (aliases, guards, pairing), and trace-analyzer.md.

wu-sheng
wu-sheng previously approved these changes Oct 21, 2026
The agent path stores an unresolved HTTP or RPC exit peer with a real id
but never registers it, so the isReal check stopped folding those
address nodes and broke the Lua e2e. Rename a destination through its
alias only when it is not a registered service, or when it is a
self-call whose alias names another service, the rows a caller that
dials its downstream by its own service name records before the alias
lands.

Revert the TAL aliasOf function and the UNDEFINED peer layer: TAL keeps
an unresolved peer as a real OTLP_SERVICE destination, as the agent
keeps a GENERAL one. The grouped-peer lookups stay in resolveAlias.

@wu-sheng wu-sheng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs and code comments below need to match the code at 083c796 before merge.

One more outside the diff: docs/en/concepts-and-designs/tal.md:46. The meta.service row still says "the resource's service.name", but the receiver passes the grouped name (shop::cart) to the analysis. Suggest: "the service name the receiver resolved: the resource's service.name, or the fallbacks in Service name, in the group::name form when service grouping applies".

Comment thread docs/en/setup/backend/otlp-trace.md Outdated
Comment on lines +149 to +151
to the node is forgotten again a day after it was read. A service first seen on another node, or a node whose read of
the stored services failed, names a grouped callee by the address row, else by the pairing, else by the step 6 guess,
until the callee's spans reach it. The group has no cluster part either: the namespace `shop` of two Kubernetes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"until the callee's spans reach it" reads as if the gap closes by itself. On a node that never receives the callee's spans, it doesn't. That happens, for example, when the collectors are pinned to another OAP node over a long-lived gRPC connection. Once the day the startup read covers has passed, the call is named by the address row when there is one, which may name either group, and otherwise by the pairing and the short-name guess. Please state the rule and the limit plainly, e.g.:

A client's peer is expected to name one target. When services of two groups are reached at the same address, such as a bare cart:7070 dialed in both namespaces, and the node cannot name the callee in the caller's own group, the call is named by the address row, which may name either group's service, as on the agent path. To pin which TAL rules analyze such spans, set their resource's service.layer to a registered layer name and declare TAL rules for that layer. Only those rules run; the shipped layer: auto rules do not. An unknown layer name is logged and its spans are not analyzed. See Layer.

Comment thread docs/en/concepts-and-designs/tal.md Outdated
Comment on lines +300 to +303
The topology query folds an address-named node into the service its address alias names once the row lands, so
those first calls join the callee's node. It folds only a node that is not a registered service: a service keeps its
own name even when the name equals some alias address, as an OTLP `frontend` does while an agent has registered the
host `frontend` for `agent::frontend`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fold has a second case in the code (ServiceTopologyBuilder.build): a self-call is folded when the address alias of its name names another service. That is the nginx-lua shape, where a caller dials its downstream by its own service name and records itself as the destination until the downstream's alias lands. Please add it here and to the matching sentence in trace-analyzer.md (line 158), e.g. "It folds a node that is not a registered service, or a self-call whose alias names another service. Other registered nodes keep their own names."

Comment thread docs/en/setup/backend/trace-analyzer.md Outdated
first calls to a freshly seen address are address-named until the alias row has reached every node's cache: up to
one persistence period (`core.default.persistentPeriod`, 25 s by default) for the row to be stored, plus the 10 s
cache reload, so about 35 s. The topology query folds those.
cache reload, so about 35 s. Those calls are drawn as a conjectured node named by the address, and the topology query folds it onto the service once the row lands.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After 0615101 the shipped rule no longer draws these as conjectured nodes. An unresolved service peer is now a normal OTLP_SERVICE destination that no service registers, and the topology query folds it because it is not a registered service. Suggest:

Those calls are drawn to a node named by the address, which no service is registered under, and the topology query folds it onto the service once the row lands.

Comment on lines +178 to +179
// A call recorded before the address's alias existed names the address itself: conjectured for a database
// peer, real for an agent's HTTP or RPC peer, and registered as a service either way never.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"registered as a service either way never" doesn't hold for the database case. A conjectured database peer is registered under its .0 id: in VIRTUAL_DATABASE by VirtualDatabaseProcessor on the agent path, and in OTLP_VIRTUAL_DATABASE by EntityTrafficGenerator.toServiceEnd on the TAL path. Under the new condition such a node keeps its own node. The .0 case here folds only because the mock registers nothing. Suggest:

An unregistered address-named destination folds through its alias, whether its id is real or conjectured.

…grouping

Name the grouped meta.service, the self-call fold, the node the topology
folds for an unresolved peer, and the limit of naming a callee two groups
reach at one address, with service.layer to pin which TAL rules run.
@wankai123
wankai123 merged commit 9a34d11 into master Oct 22, 2026
478 of 483 checks passed
@wankai123
wankai123 deleted the feature/otlp-service-group branch October 22, 2026 05:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend OAP backend related. enhancement Enhancement on performance or codes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants