Skip to content

Analyze OTLP spans opt-in by service.layer, and file the OTLP service catalog by layer - #14170

Merged
wu-sheng merged 9 commits into
masterfrom
feature/tal-claimed-spans
Oct 29, 2026
Merged

wu-sheng merged 9 commits into
masterfrom
feature/tal-claimed-spans

Conversation

@wankai123

@wankai123 wankai123 commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Analyze OTLP spans opt-in by service.layer, and file the OTLP service catalog by layer

  • If this is non-trivial feature, paste the links/URLs to the design doc. The behaviour is described in the updated operator docs: trace-analyzer.md, tal.md, otlp-trace.md, traceql-service.md.
  • 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.

TAL is opt-in by layer. The shipped rule file tal/otlp-default.yaml is renamed tal/otlp-general.yaml, and its rules otlp-general and otlp-slow-sql are declared for OTLP_GENERAL (renamed from OTLP_SERVICE, ordinal 52). Only spans whose resource carries service.layer=OTLP_GENERAL are analyzed; the others are stored and searchable. When no layer: auto rule is installed, the OAP logs one INFO line per service whose spans carry no layer. OTLP_MESH (56) is a new layer with no rules, for service mesh sidecars. Unlike LAL, TAL has no layer: auto: a rule is declared for a registered layer and sees only the spans that carry it, and a rule declaring auto, or no layer, stops the OAP from starting. A sender that cannot be changed gets its service.layer from the OpenTelemetry Collector's resource processor instead.

Claimed spans. Only a span a rule claims, one that ran without abort {}, names its service for host resolution and sends a pairing fact; only a span some rule runs on takes a dedup slot.

The OTLP service catalog keeps the layer. OTLPServiceTraffic stores each service's service.layer in otlp_service_layer (a column name unique in the merged Elasticsearch metrics index): a registered layer, else UNDEFINED. There is one row per name and layer. IOTLPTraceQueryDAO#getServiceNames is replaced by getServices(layer), which filters in storage on BanyanDB, Elasticsearch and JDBC. The stored OTLP span's resource service.layer is set to the same value, so a TraceQL search on the layer agrees with the catalog, while TAL reads the layer as sent.

TraceQL. A q whose only condition is the scoped resource.service.layer is answered from the catalog for resource.service.name, resource.service and service.name.

Tests. New unit tests cover the catalog row, the three DAOs' layer filter, the TraceQL layer filter and the stored layer. The renamed tests and e2e files are updated: the native otlp-traces compose sets service.layer=OTLP_GENERAL, and the config dump expects otlp-general.

Verified on kind with the showcase: the OpenTelemetry Astronomy Shop under OTLP_GENERAL, Istio sidecars exporting OTLP under OTLP_MESH, and unlabelled and misspelled senders under UNDEFINED.

  • If this pull request closes/resolves/fixes an existing issue, replace the issue number. Closes #.
  • Update the CHANGES log.

… catalog by layer

The trace analyzer's shipped rules (tal/otlp-general.yaml, formerly otlp-default.yaml) are
declared for OTLP_GENERAL, renamed from OTLP_SERVICE, so only spans whose resource carries
service.layer=OTLP_GENERAL are analyzed; the others are stored and searchable. OTLP_MESH is a
new layer with no rules for service mesh sidecars. Only spans a rule claims name a service for
host resolution and send pairing facts, and only spans some rule runs on take a dedup slot.

The OTLP service catalog keeps each service's layer (otlp_service_layer; a registered layer,
else UNDEFINED), one row per name and layer, and IOTLPTraceQueryDAO#getServices(layer) filters
in storage. The stored span's service.layer carries the same value, so a TraceQL search on the
layer agrees with the catalog, which answers a layer-only filter on the service name tags.
…-spans

# Conflicts:
#	docs/en/changes/changes.md
@wu-sheng wu-sheng added this to the 11.1.0 milestone Oct 26, 2026
@wankai123
wankai123 requested a balanced review from Copilot October 26, 2026 07:22

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

Catalog identity collisions can lose service entries, and client pairing publication is not yet gated by a successful span claim.

2 open issues
What changed in this PR

This PR makes OTLP trace analysis opt-in through service.layer and carries that layer through SkyWalking’s native OTLP storage and TraceQL service catalog.

Changes:

  • Renames the shipped TAL rules and layer to OTLP_GENERAL, adds OTLP_MESH, and limits service registration and pairing to claimed spans.
  • Stores catalog entries by service and layer, normalizes stored resource layers, and adds storage-side filtering for TraceQL service pickers.
  • Updates operator documentation, configuration defaults, unit tests, and end-to-end fixtures.
File Description
test/​e2e-v2/​cases/​storage/​expected/​config-dump.yml Expects the renamed default TAL file.
test/​e2e-v2/​cases/​otlp-traces/​native/​tal-cases.yaml Queries the OTLP_GENERAL layer.
test/​e2e-v2/​cases/​otlp-traces/​native/​expected/​tal-topology.yml Updates expected topology layers.
test/​e2e-v2/​cases/​otlp-traces/​native/​expected/​tal-services.yml Updates expected service layers.
test/​e2e-v2/​cases/​otlp-traces/​native/​docker-compose-demo.yml Opts demo services into analysis.
oap-server/​server-storage-plugin/​storage-jdbc-hikaricp-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​storage/​plugin/​jdbc/​common/​dao/​JDBCOTLPServiceCatalogTest.java Tests JDBC layer filtering and deduplication.
oap-server/​server-storage-plugin/​storage-jdbc-hikaricp-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​storage/​plugin/​jdbc/​common/​dao/​JDBCOTLPTraceQueryDAO.java Reads layer-aware JDBC catalog rows.
oap-server/​server-storage-plugin/​storage-elasticsearch-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​storage/​plugin/​elasticsearch/​query/​otlp/​OTLPTraceQueryEsDAO.java Filters Elasticsearch catalog rows by layer.
oap-server/​server-storage-plugin/​storage-banyandb-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​storage/​plugin/​banyandb/​trace/​BanyanDBOTLPTraceQueryDAOTest.java Adopts the updated catalog API.
oap-server/​server-storage-plugin/​storage-banyandb-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​storage/​plugin/​banyandb/​trace/​BanyanDBOTLPServiceCatalogTest.java Tests BanyanDB layer-condition placement.
oap-server/​server-storage-plugin/​storage-banyandb-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​storage/​plugin/​banyandb/​trace/​BanyanDBOTLPTraceQueryDAO.java Reads and filters layer-aware catalog rows.
oap-server/​server-starter/​src/​main/​resources/​tal/​otlp-general.yaml Restricts shipped rules to OTLP_GENERAL.
oap-server/​server-starter/​src/​main/​resources/​application.yml Changes the default rule file and deduplication guidance.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​ShippedRuleOverRecordedTraceTest.java Loads the renamed shipped rule.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​OTLPStoredLayerHandlerTest.java Tests stored versus analysis layer values.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​OTLPSpanForwardCatalogLayerTest.java Tests resource normalization and catalog layers.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​OTLPSpanForward.java Normalizes resource layers and populates catalog layers.
oap-server/​server-receiver-plugin/​otel-receiver-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​receiver/​otel/​otlp/​OpenTelemetryTraceHandler.java Separates received and stored resource layers.
oap-server/​server-query-plugin/​traceql-plugin/​src/​test/​java/​org/​apache/​skywalking/​oap/​query/​traceql/​handler/​OTLPTraceQLServiceLayerTest.java Tests catalog-backed layer filtering.
oap-server/​server-query-plugin/​traceql-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​query/​traceql/​handler/​TraceQLApiHandler.java Shares filter classification helpers.
oap-server/​server-query-plugin/​traceql-plugin/​src/​main/​java/​org/​apache/​skywalking/​oap/​query/​traceql/​handler/​OTLPTraceQLApiHandler.java Answers layer-only service queries from the catalog.
oap-server/​server-core/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​core/​query/​OTLPTraceQueryServiceTest.java Tests service-name deduplication and filter forwarding.
oap-server/​server-core/​src/​test/​java/​org/​apache/​skywalking/​oap/​server/​core/​otlp/​OTLPServiceTrafficTest.java Tests layer identity, serialization, and defaults.
oap-server/​server-core/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​core/​storage/​query/​IOTLPTraceQueryDAO.java Defines the layer-aware catalog contract.
oap-server/​server-core/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​core/​query/​OTLPTraceQueryService.java Filters catalog reads and deduplicates names.
oap-server/​server-core/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​core/​otlp/​source/​OTLPService.java Adds the catalog layer to service sources.
oap-server/​server-core/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​core/​otlp/​OTLPServiceTraffic.java Persists layer-specific catalog identities.
oap-server/​server-core/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​core/​otlp/​dispatcher/​OTLPServiceDispatcher.java Propagates the source layer into traffic rows.
oap-server/​server-core/​src/​main/​java/​org/​apache/​skywalking/​oap/​server/​core/​analysis/​Layer.java Renames layer 52 and registers OTLP_MESH.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​ShippedRules.java Points shared test helpers at the renamed file.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​TraceAnalyzerModuleProviderTest.java Updates stored-service loading expectations.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​span/​TraceAnalyzerServiceImplTest.java Tests claimed-span registration and pairing.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​span/​listener/​SpanFilterListenerFactoryTest.java Uses the renamed layer in rule validation.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​ShippedTalFileTest.java Verifies shipped rules explicitly target OTLP_GENERAL.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​dsl/​TALWorkedExampleTest.java Updates worked examples and expected entities.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​dsl/​TALExecutionTest.java Updates runtime tests to renamed rules and layers.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​compiler/​TALSourceAttributionTest.java Updates generated-source attribution expectations.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​compiler/​TALShippedRuleCompileTest.java Compiles the renamed shipped rule.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​compiler/​TALScriptParserTest.java Updates parser source-text expectations.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​compiler/​TALClassGeneratorTest.java Uses the renamed layer in compiler fixtures.
oap-server/​analyzer/​trace-analyzer/​src/​test/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​compiler/​DumpShippedRuleSourcesTest.java Updates generated-file naming expectations.
oap-server/​analyzer/​trace-analyzer/​src/​main/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​TraceAnalyzerModuleProvider.java Loads known services from OTLP_GENERAL.
oap-server/​analyzer/​trace-analyzer/​src/​main/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​TraceAnalyzerModuleConfig.java Sets the renamed default TAL file.
oap-server/​analyzer/​trace-analyzer/​src/​main/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​TALConfig.java Updates the source-path documentation example.
oap-server/​analyzer/​trace-analyzer/​src/​main/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​span/​TraceAnalyzerServiceImpl.java Gates deduplication, registration, and server pairing.
oap-server/​analyzer/​trace-analyzer/​src/​main/​java/​org/​apache/​skywalking/​oap/​trace/​analyzer/​v2/​provider/​span/​listener/​SpanFilterListener.java Exposes whether auto-layer rules are installed.
oap-server/​analyzer/​trace-analyzer/​src/​main/​antlr4/​org/​apache/​skywalking/​tal/​rt/​grammar/​TALParser.g4 Updates a comment example; grammar is unchanged.
docs/​en/​setup/​backend/​trace-analyzer.md Documents opt-in analysis, mesh labeling, and deduplication.
docs/​en/​setup/​backend/​otlp-trace.md Documents stored-layer normalization and opt-in behavior.
docs/​en/​setup/​backend/​configuration-vocabulary.md Updates rule defaults and capacity guidance.
docs/​en/​concepts-and-designs/​tal.md Explains layer routing and claimed-span behavior.
docs/​en/​changes/​changes.md Records analysis and catalog changes.
docs/​en/​api/​traceql-service.md Documents layer-filtered service catalogs.

Review effort: Balanced

A TAL rule is declared for a registered layer and sees only the spans that carry it; a span
without a registered service.layer is stored, not analyzed. An auto rule picked the layer from the
span, naming the service under a layer its stored spans and the OTLP service catalog did not carry,
and would analyze unlabelled mesh sidecars. A rule declaring `auto`, or no layer, now stops the OAP
from starting; a sender that cannot be changed gets service.layer from the collector instead.
@wu-sheng

wu-sheng commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Proposal: build TAL network aliases from strict span pairing only, keyed by caller service + peer

This is for the next update of this PR, which turns the client/server mapping into a TAL option. The goal is to stop deriving network aliases from name guessing and from server.address, so that KnownServices can be removed.

Why the current sources are not reliable

  • alias dialed registers an end-to-end address, not this hop's. The OTel HTTP semconv takes a server span's server.address / server.port from Forwarded#host / X-Forwarded-Host, then :authority, then Host. That is the original host, "intended, whenever possible, to be the same on the client and server sides" (http-spans.md). Behind a proxy that keeps the host, the backend therefore reports the proxy's address. The Astronomy Shop trace recorded in this repo (otlp-trace-get-api-cart.json) shows this. frontend's SERVER span carries server.address=frontend-proxy and server.port=8080, and its parent is Envoy's CLIENT span router frontend egress with peer.address=172.19.0.19:8080.

  • Proxy defaults, read from source:

    • Envoy v1.40.2 keeps :authority unless a host_rewrite_* option is set. append_x_forwarded_host defaults to false and applies only together with a rewrite (route_components.proto).
    • nginx 1.31.8 sends the proxy_pass target as Host unless proxy_set_header Host is configured, and adds no X-Forwarded-* header by default (ngx_http_proxy_module.c).
    • Spring Cloud Gateway 5.0.4 sets Host from the route URI unless PreserveHostHeader is on (NettyRoutingFilter). It registers the X-Forwarded-* / Forwarded filters only when spring.cloud.gateway.server.webflux.trusted-proxies is set (TrustedProxies).
  • The KnownServices guard works only by name coincidence. AliasRegistrar.register refuses an address only when its host equals the name of a service whose claimed spans reached this node. It misses three cases:

    • a proxy dialed by a host other than its service.name;
    • a proxy whose spans are not claimed, which after this PR is any proxy without service.layer=OTLP_GENERAL;
    • a node that has never seen the proxy's spans.

    It also refuses unrelated hosts that happen to equal a known short name.

  • An address-only key cannot resolve per caller. NetworkAddressAlias is keyed by address alone and the last write wins. So cart:7070 dialed from two groups cannot map to two different services, which is why group resolution lives in KnownServices today.

Pairing works hop by hop: it matches a CLIENT span with its direct child SERVER span. On the same recorded trace it gives two aliases, whatever the Host and X-Forwarded-* headers say:

  • frontend-proxy:8080 → frontend-proxy, from load-generator's CLIENT span and Envoy's SERVER span;
  • 172.19.0.19:8080 → frontend, from Envoy's CLIENT span and frontend's SERVER span.

Proposal

The foundation, and a hard requirement: caller.spanId == callee.parentSpanId. A network alias is built only from a CLIENT span and the SERVER span, in the same trace, whose parentSpanId equals that CLIENT span's spanId. This is how the server side finds its match. Names, addresses and attributes never pair two spans.

1. Three TAL functions

Function Used on What it does
prepareNetworkAlias peer CLIENT span Sends the PairingFact.
buildNetworkAlias <address> SERVER span On the trace owner node, finds the PairingFact whose spanId equals this span's parentSpanId, then writes the NetworkAddressAlias row under the address the rule composes from that PairingFact.
resolveNetworkAlias(<address>) CLIENT span Looks up the address the rule composes the same way at ingest, so the relation metrics land on the callee's node. Replaces resolveAlias.

2. The rule composes the alias address: caller service + peer

The alias address is built by the TAL rule itself, on the server side, and the client side composes it the same way to look it up. The shipped rule uses:

callerScopedAddress(service, peer) = service + "->" + peer
  • Server side, building the row: buildNetworkAlias callerScopedAddress(pairing.service, pairing.peer). It is evaluated on the trace owner after the match, so it reads only the matched PairingFact (pairing.*). The callee half carries its rule's key, so the owner node knows which expression to run.
  • Client side, looking it up: resolveNetworkAlias(callerScopedAddress(meta.service, peer)).
  • Query side, folding: ServiceTopologyBuilder folds the edges recorded before the alias landed by composing callerScopedAddress(source, dest).
  • One implementation: callerScopedAddress is a TAL function backed by one static in server-core, for example IDManager.NetworkAddressAliasDefine.buildCallerScopedAddress, next to buildId(address). The query fold calls that same static. It has to live in core because ServiceTopologyBuilder is in core, and core cannot depend on the optional trace-analyzer module.
  • Normalization: service is the formatted name that the service id carries, and peer is the address node's name in the topology. All three sites normalize them the same way.
  • No overlap with agent rows: only OTLP analysis writes prefixed rows. The agent path keeps writing plain addresses, and a plain-address lookup never matches svc->addr, so the two kinds of row never collide. -> is used rather than |, which OAP already uses as Const.ARRAY_SPLIT. A peer address never contains ->, so a composed address maps back to exactly one caller and peer.
  • Nothing new to build: NetworkAddressAlias, NetworkAddressAliasSetup, CacheUpdateTimer and NetworkAddressAliasCache are reused as they are. There is no new stream, no new DAO and no schema change.

3. PairingFact and the build

Caller half (prepareNetworkAlias) Callee half (buildNetworkAlias)
Fields traceId, spanId, parentSpanId, service (meta.service), peer, span kind, timestamp traceId, spanId, parentSpanId, service, normal, timestamp
Sent when CLIENT span only. Not resent for a key learned or loaded in the last 10 minutes. Sampled by pairingSamplePercent in a cluster. SERVER span whose parent ran in another process.

Both halves are routed to the trace owner by trace id, as they are now. On the owner:

  1. Match on caller.spanId == callee.parentSpanId, the hard requirement. The server side looks up the PairingFact with the same traceId whose spanId equals its own parentSpanId. Nothing else can pair two spans. The half that arrives first waits up to 60 seconds, with at most 200k entries buffered.
  2. Build only when all of these hold:
    • the PairingFact came from a CLIENT span;
    • the caller and callee services differ;
    • peer is not listed in gateways.yml.
  3. Write a NetworkAddressAliasSetup with:
    • address = the value of the buildNetworkAlias expression, which in the shipped rule is callerScopedAddress(pairing.service, pairing.peer);
    • the callee as the represented service, with its normal flag;
    • no instance.

If the composed address is longer than the address column (200 by default), no row is built and resolveNetworkAlias returns the peer unchanged. Each case is counted, with at most one WARN a minute. serviceNameMaxLength is configurable, so this check is needed.

4. Lookups

  • At ingest: resolveNetworkAlias(callerScopedAddress(meta.service, peer)) first reads this node's learned cache (10 minutes), then NetworkAddressAliasCache. It returns the callee's name on a hit, and the peer otherwise.
  • At query time: for a destination that is not a registered service, ServiceTopologyBuilder first looks up callerScopedAddress(source, dest). That folds the OTLP edges recorded before the alias landed into the callee's node. It then falls back to the plain address for agent rows, as it does today.

5. Messaging stays producer → MQ → consumer, with no alias. CONSUMER spans do not build and PRODUCER spans do not prepare. A consumer's parent is the producer span, so an alias would move the producers' edges off the broker. That is also why TAL (TraceAnalyzerServiceImpl) and the agent path (NetworkAddressAliasMappingListener) already exclude MQ.

6. Removed

  • KnownServices and the boot-time recordStoredServices.
  • AliasRegistrar's name resolution (serviceNamedBy, serviceByShortName), together with its proxy and short-name guards.
  • The alias statement (TalRuntimeHelper.alias and aliasStatement).
  • resolveAlias (TalRuntimeHelper.resolveAlias).

None of this has been released, so no compatibility path is needed, and the changelog note goes into the TAL entry.

7. Shipped rule (sketch)

if (meta.kind == "SERVER") {
  // Server side: the alias address is caller service + the peer that caller dialed, from the matched PairingFact.
  buildNetworkAlias callerScopedAddress(pairing.service, pairing.peer)
  ...
}
if (meta.kind == "CLIENT" || meta.kind == "PRODUCER") {
  def peer = ...
  if (meta.kind == "CLIENT" && !isGateway(peer)
      && !hasAttr("db.system.name", "db.system") && !hasAttr("messaging.system")) {
    prepareNetworkAlias peer
  }
  if (!isGateway(peer)) {
    // Client side: compose the same address to look it up.
    def peer = resolveNetworkAlias(callerScopedAddress(meta.service, peer))
  }
  ...
}

A database, cache or broker never answers with a SERVER span, so its peer is only looked up, never prepared. The slow-SQL rule (L317) looks it up the same way.

8. Costs to document

  • Each caller + peer pair has to be learned once by pairing. Until then, its calls are address-named and get folded at query time. At the default 25% sampling in a cluster, that is about four calls.
  • A peer dialed by a Kubernetes Service name is no longer resolved from the name alone.
  • The callee must be instrumented and its spans claimed. Otherwise its callers stay address-named.
  • An uninstrumented proxy or load balancer in front of several backends has to be listed in gateways.yml, as on the agent path.
  • An agent-instrumented service that calls an OTLP service by address is no longer folded, because TAL no longer writes plain-address rows.

9. Extra key parts later. A rule that needs more than caller + peer, such as an attribute or a processed URL segment, changes the expressions of its buildNetworkAlias and resolveNetworkAlias together. The query-time fold only knows an edge's source and destination, so it can reproduce caller + peer and nothing more. Edges recorded before such an alias lands are not folded, and rows written in the old format stop matching until they expire with the TTL. That change should say so in its changelog.

10. Docs. Rewrite the "Address-named peers" section of tal.md to cover the three functions, the caller-scoped format, the strict match and the costs above. Update trace-analyzer.md and the TAL changelog entry to match.

@wu-sheng

Copy link
Copy Markdown
Member

Follow-up: canary routed by an HTTP header

This builds on the network alias proposal. The shipped rule stays as proposed. This case belongs on a best-practice page.

The case

a calls b:8080. A request header, say x-canary: v2, makes the router send the call to b'. b' reports its own service.name, for example b-canary. Both calls have the same caller and the same peer, so pairing writes both to the same row, a->b:8080. The last write wins. The row flips between b and b-canary, and a's edge follows whichever wrote last.

If b' reports the same service.name as b, with its own service.version or instance, nothing needs to change. a->b:8080 → b is correct for both, and the canary shows up at the instance level.

The key: put the version into the peer

callerScopedAddress(meta.service, peer + "(" + version + ")")   // a->b:8080(v2)

Only calls that carry the header get the suffix. Stable traffic keeps the shipped key a->b:8080, so adding this to a rule leaves the rows already written valid.

What changes in TAL: only the CLIENT block of the rule

Part Change
CLIENT block of the rule Reads the routing header and appends the version to peer before prepareNetworkAlias and resolveNetworkAlias.
SERVER block None. pairing.peer is the peer the caller prepared, so after the caller.spanId == callee.parentSpanId match the owner builds a->b:8080(v2) → b-canary.
prepareNetworkAlias, buildNetworkAlias, resolveNetworkAlias, callerScopedAddress, PairingFact None.
Query-time fold in ServiceTopologyBuilder None. Calls made before the alias lands are recorded against the node b:8080(v2), so the fold composes a->b:8080(v2) from the edge's source and destination and finds the row.

This is why the version belongs in the peer. Item 9 of the proposal still applies to a key part kept outside the peer: the fold cannot rebuild it. A part written into the peer is folded like any other.

The whole change, against the shipped rule sketch in item 7 of the proposal:

 if (meta.kind == "SERVER") {
   buildNetworkAlias callerScopedAddress(pairing.service, pairing.peer)
   ...
 }
 if (meta.kind == "CLIENT" || meta.kind == "PRODUCER") {
   def peer = ...
+  // The canary header decides which service answers the call, so it is part of the alias key.
+  if (!isGateway(peer) && attr("http.request.header.x-canary") == "v2") {
+    def peer = peer + "(v2)"
+  }
   if (meta.kind == "CLIENT" && !isGateway(peer)
       && !hasAttr("db.system.name", "db.system") && !hasAttr("messaging.system")) {
     prepareNetworkAlias peer
   }
   if (!isGateway(peer)) {
     def peer = resolveNetworkAlias(callerScopedAddress(meta.service, peer))
   }
   ...
 }

The new if checks isGateway first because isGateway(peer) is false once the peer ends in (v2). Without that check, a gateway's address would get the suffix and then be prepared and looked up like any other peer.

Prerequisites and limits, for the doc

  • The routing key must be on the caller's CLIENT span. http.request.header.<key> is Opt-In, a string[] (semconv). For the Java agent the setting is otel.instrumentation.http.client.capture-request-headers=x-canary (docs). TAL reads a string[] joined by ,, so a single header value reads as v2. Capturing it on the callee is not needed.
  • Match known values, not the raw header. The value comes from whoever sent the request. Appending it as it is would add a destination node and an alias row for every distinct value. A value the rule doesn't list stays on the plain key.
  • A weight-based split cannot be told apart. With a 90/10 split, nothing on the caller's span says where the call went, so a->b:8080 keeps flipping. For that, report the canary under b's service.name with its own service.version.
  • Learning takes longer. Until a->b:8080(v2) is learned, its calls show on the node b:8080(v2) and are folded at query time. Canary traffic is usually a small share, and in a cluster pairing samples 25% of traces by default. The fold moves the node and the edge, not the metrics: calls recorded during that window stay under the relation a → b:8080(v2), the same as for any other caller + peer pair.
  • Length. The suffix counts toward the 200-character address limit.

Docs

The shipped otlp-general.yaml stays unchanged. Add a page, docs/en/setup/backend/trace-analyzer-practices.md, titled "OpenTelemetry Trace Analysis Practices", next to "OpenTelemetry Trace Analysis" in menu.yml. Link it from the network-alias section of tal.md and from trace-analyzer.md.

Outline:

  1. What the page is. Each scenario is a change to your own copy of a rule under config/tal/. The shipped rules don't change. As with any rule change, every OAP node gets the same files, and the change takes effect after a restart.
  2. How the alias key works, briefly, with a link to tal.md for the details:
    • the key is caller service->peer;
    • the client composes it to look it up, and the trace owner builds it after the strict caller.spanId == callee.parentSpanId match;
    • the topology fold rebuilds the key from an edge's source and destination.
  3. Extending the key.
    • Write the extra part into the peer, so the fold can still rebuild the key.
    • Match known values instead of copying raw attribute values.
    • Keep the result within the 200-character address limit.
  4. Scenario: canary routed by an HTTP header.
    • When it's needed: the canary reports its own service.name. When it's not: the canary shares b's service.name and differs only in service.version.
    • Step 1: capture the header on the caller, with the Java agent setting as the example and other SDKs pointed to their own docs.
    • Step 2: the rule diff above.
    • What you see: during learning, the node b:8080(v2); after learning, a → b and a → b-canary. Calls made during learning stay under the address relation.
    • Limits: a weight-based split, and header values the rule doesn't list.

…nd peer

Network address aliases come only from the two spans of one call: a SERVER
span whose parentSpanId is the CLIENT span's spanId. Each alias is keyed by
the caller and the address it dialed, `frontend->cart:7070`, so one address
dialed by two callers can name two services, and a proxy that keeps the Host
header can no longer hand its address to the service behind it.

- TAL: `prepareNetworkAlias` (CLIENT), `buildNetworkAlias` (SERVER, composed
  on the trace owner from `pairing.service` / `pairing.peer`),
  `callerScopedAddress` and `resolveNetworkAlias` replace the `alias`
  statement and `resolveAlias`; `KnownServices`, the host/DNS-label guard and
  the startup read of stored services are removed.
- Pairing: two halves meet atomically on the trace owner; a learned pair is
  rebuilt once its row is ten minutes old by the OAP clock; at most one
  caller half per caller + peer every 30 s; `pairingSamplePercent` applies on
  a single node too; uncomposed and too-long pairs are counted and warned.
- Topology: the fold tries the caller-scoped row before the plain address and
  keeps the row's own service id; a call to another replica is a loop.
- Gateways: never aliased; a bare host is named `host:80` on both sides.
- Docs: tal.md, trace-analyzer.md, otlp-trace.md and a new OpenTelemetry
  Trace Analysis Practices page.
@wankai123
wankai123 requested a balanced review from Copilot October 28, 2026 09:10

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@wu-sheng

wu-sheng commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Review of c2feed8b94: network alias redesign

The pairing follows the agreed design: the strict caller.spanId == callee.parentSpanId match, owner-side buildNetworkAlias, caller-scoped keys, one buildCallerScopedAddress in core shared by the rules and the topology fold, and KnownServices with its guards removed. The changes below are needed before merge.

1. Doc fixes

1.1 The canary recipe needs the gateway guard. trace-analyzer-practices.md:123 appends the version to any peer. For a caller that dials a gateway listed in gateways.yml, such as an ingress routing by canary-by-header, the peer becomes ingress:80(v2), which no longer matches the file. As a result:

  • otlp-general.yaml:196 prepares and resolves it;
  • otlp-general.yaml:217 gives it OTLP_GENERAL instead of VIRTUAL_GATEWAY;
  • SpanPairing.java:221 checks the suffixed peer and builds caller->ingress:80(v2) → backend.

So the canary calls skip the gateway node, while the stable calls still end on it. Guard it:

if (peer && !isGateway(peer) && attr("http.request.header.x-canary") == "v2") {
  def peer = peer + "(v2)"
}

Update TALWorkedExampleTest.theCanaryRecipeOfThePracticesDocLandsEachCallOnItsOwnService the same way, and add a call to a listed gateway that still ends on the gateway node.

1.2 An uninstrumented gateway with several backends is supported. The practices table (L34), its section (L83) and trace-analyzer.md:232 call it "not supported". With the gateway listed:

  • the shipped rule never prepares a gateway peer, and SpanPairing.build skips one, so nothing is aliased;
  • the caller's edge ends on [gateway];
  • each backend draws its own [gateway] → backend edge from its server.address (otlp-general.yaml:148) when the Host is kept.

Several backends therefore draw the same as one, as on the agent path, and as tal.md already says for a load balancer in front of a fleet. Document it as supported, keeping the Host and trace-context conditions. The case that cannot work is such a gateway not listed, whose address the pairs would hand to each backend in turn.

1.3 Changelog. TAL is not in any release yet, so the release notes should not announce changes to it. In the bullet at changes.md:75, "the alias statement and resolveAlias are replaced", "KnownServices … removed", "pairingSamplePercent now applies on a single node too" and "no longer folded" describe things no release had. The same goes for "TAL: a span no rule claims sends no pairing fact …". Fold what is still worth saying into the TAL entry, in present tense, and drop the rest.

2. Remove layer: auto from TAL entirely

TAL has no layer: auto, so nothing needs to mention it, and an auto layer fails like any unregistered one:

  • SpanFilterListener.java:166-171: drop the "auto" special case. Layer.valueOf("auto") then refuses the rule with the existing "not registered" message.
  • SpanFilterListener.java:50-52: drop the "Unlike LAL there is no layer: auto" Javadoc sentence.
  • OTLPSpanForward.java:199: drop "not one a layer: auto rule assigned to a span that carries none".
  • SpanFilterListenerFactoryTest.aRuleOnLayerAutoIsRefused: remove it with the special case. The unregistered-layer test above it covers the case.
  • TraceAnalyzerServiceImplTest:323: drop the "No layer: auto:" prefix of the comment.
  • tal.md:72-75: drop the layer: auto part, and keep the sentence about inserting service.layer in the Collector.
  • tal.md:401: drop the "No layer: auto" bullet.
  • changes.md:43: drop "and unlike LAL there is no layer: auto".

3. Remove the extra caches and guards; let NetworkAddressAlias.combine decide the writes

Updated: the write decision moves into NetworkAddressAlias.combine, in place of the owner-side alias-cache check first proposed here.

Two caches stay: the core NetworkAddressAliasCache, which every node reads, and the pairing buffers (callers / callees), where the two halves of a call meet. Remove the rest: learned, reported, recentlySent, the two skips in prepareNetworkAlias and LEARNED_TTL_MINUTES. Instead of them, one change in core decides when an alias is written.

Why the guards can go

  • reported (SpanPairing.java:69) is keyed by traceId:spanId, so it never holds back a distinct call. It only catches one span prepared twice: by two rules of one layer, which the docs already rule out, or by an exporter retry, which OTLP allows and dedupWindow (0 by default) does not filter. Such a duplicate pairs into the same mapping, and the combine below writes nothing for it.
  • learned (SpanPairing.java:64, filled at L241) lives on the node that built the row, the trace's owner, picked per trace by traceId.hashCode(). With separate Receiver and Aggregator roles, the receiver resolving a caller never holds it; on mixed nodes it hits only when that node happened to build the row. What it bridges is one persistence period plus the 10-second cache reload, the alias lag the topology fold already covers.
  • recentlySent (SpanPairing.java:75) throttles caller halves per receiving node, keyed by the alias-address format. It saves no write (see below), only facts, whose volume pairingSamplePercent already sets.
  • The skips in prepareNetworkAlias (TalRuntimeHelper.java:268-276) read learned and the stored row's age. They are the client-side half of a refresh that combine now does where the row is written. Without them, the client sends every sampled caller half, so a changed mapping, such as a pod IP reused by another service, is found at its next sampled call.

Why writes are the thing to optimize

NetworkAddressAlias.combine always overwrites the mapping and lastUpdateTimeBucket and returns true, so L2 writes every alias that reaches it at every drain (MetricsPersistentWorker.java:266-276), changed or not: about every 25 seconds (persistentPeriod), some 140 writes an hour for each live alias. L1 and L2 already merge setups of one alias id within a drain (MergableBufferedData); what is missing is skipping a write that changes nothing. Metrics.combine already has the contract for it: false means abandoned, data unaltered, and L2 skips the write.

The proposal: refresh in combine

// NetworkAddressAlias
private static final long REFRESH_MILLIS = TimeUnit.MINUTES.toMillis(10);

@Override
public boolean combine(final Metrics metrics) {
    final NetworkAddressAlias alias = (NetworkAddressAlias) metrics;
    final boolean sameMapping = Objects.equals(representServiceId, alias.getRepresentServiceId())
        && Objects.equals(representServiceInstanceId, alias.getRepresentServiceInstanceId());
    if (sameMapping && TimeBucket.getTimestamp(alias.getLastUpdateTimeBucket())
        - TimeBucket.getTimestamp(lastUpdateTimeBucket) < REFRESH_MILLIS) {
        return false;   // unchanged and written recently: no write, nothing altered
    }
    // changed, or unchanged but due for a refresh
    this.representServiceId = alias.getRepresentServiceId();
    this.representServiceInstanceId = alias.getRepresentServiceInstanceId();
    this.lastUpdateTimeBucket = alias.getLastUpdateTimeBucket();
    if (this.getTimeBucket() > metrics.getTimeBucket()) {
        this.setTimeBucket(metrics.getTimeBucket());
    }
    return true;
}
  • Writes: a changed mapping is written at the next drain; an unchanged one once per 10 minutes, about 6 writes an hour instead of 140.
  • Compare timestamps, not bucket numbers: lastUpdateTimeBucket is yyyyMMddHHmm, so subtracting two buckets is not minutes across an hour or a day.
  • Return false before altering anything, the timeBucket min-update included, as the contract requires.
  • 10 minutes or less keeps every live alias inside the incremental cache reload, which loads rows with lastUpdateTimeBucket in the last 10 minutes (CacheUpdateTimer.java:105-115). A node that evicted it under maxSizeOfNetworkAddressAlias, or restarted, gets it back within one interval, and a restart's full load, which looks back metricsDataTTL (7 days) by lastUpdateTimeBucket, always finds it, including on BanyanDB, whose metadata group keeps rows for 15 days.
  • Retention: storage still expires a row by its first timeBucket (Elasticsearch and JDBC by metricsDataTTL, BanyanDB by the metadata group TTL). L2's expiry check (MetricsPersistentWorker.java:328-336) reloads such a row from storage, and inserts it fresh once storage has dropped it, as long as setups keep arriving.
  • The agent path benefits too, since NetworkAddressAlias is shared: a stable plain-address alias drops from about 140 writes an hour to 6. A different instance behind the address is a change and is written at once, as now. It deserves its own changelog line.

The workflow

  1. CLIENT span: prepareNetworkAlias peer sends the caller half of every sampled call; clientSeen checks the kind and the sample.
  2. SERVER span: buildNetworkAlias sends the callee half, as now.
  3. Owner node: match on caller.spanId == callee.parentSpanId, compose the address with the callee rule's buildNetworkAlias, and push a NetworkAddressAliasSetup into L1 for every matched pair. No cache is checked here.
  4. L1 / L2: merge by alias id; combine decides whether L2 writes.
  5. Lookups: resolveNetworkAlias and the topology fold read the alias cache only.

Every sampled CLIENT call then sends a caller half, as every sampled SERVER span already sends a callee half; pairingSamplePercent is the knob. A caller half to a peer that never answers, an external API, waits out its 60 seconds in the bounded buffer (200,000 halves), and the sender queue drops when full (10,000).

Tests follow the new workflow

  • Remove: SpanPairingTest.aSpanPreparedByTwoRulesSendsOneFact, aSecondPeerOfAReportedSpanKeepsItsSlot, aPairSendsOneCallerHalfPerInterval.
  • Rewrite:
    • anUnsampledCallDoesNotTakeThePairsSlot: an unsampled call sends nothing;
    • TALExecutionTest.resolveNetworkAliasLearnsTheCalleeFromTheServerSpanItsCallerCalled: cold fallback, setup pushed, still the peer before the cache reload, the callee after it;
    • prepareNetworkAliasPairsAgainOnceTheStoredRowIsNoLongerRebuilt: a fresh cached row naming A does not stop the half that finds B;
    • prepareNetworkAliasLooksNothingUpForASpanThatIsNotAClient: no cache lookup is expected any more;
    • the SpanPairingTest cases that observe results through learnedService, the concurrent one included: observe the pushed setups instead.
  • Add core tests for NetworkAddressAlias.combine: same mapping within the interval returns false and alters nothing; same mapping after it returns true and moves lastUpdateTimeBucket; another service or instance returns true; an interval across an hour boundary; timeBucket keeps the first insert.
  • ShippedRuleOverRecordedTraceTest: the cold pass no longer names Envoy's callee on the spot (frontend-proxy -> 172.19.0.19:8080 until the cache reload); the warm pass pushes a setup per matched pair with unchanged mappings, and the write decision is covered by the combine tests.

Docs to update: tal.md:310-317, :329, :337 and :344; trace-analyzer.md:48 and the buffer paragraph at :251-256; application.yml:286-291; TraceAnalyzerModuleConfig.java:64; trace-analyzer-practices.md:14; the refresh and 30-second sentences in changes.md:75.

@wu-sheng

Copy link
Copy Markdown
Member

Practices page: what is missing

This replaces 1.1 and 1.2 of my review comment. 1.2 is withdrawn: an uninstrumented gateway that splits one address by blue/green or weight is unsupported, as the page says.

These practices exist to draw a correct topology: the gateway where it is, and each destination behind it on its own edge. The page covers the uninstrumented gateway in gateways.yml, the unsupported blue/green case, and the instrumented gateway's checklist. Missing:

  1. The canary section should lead with tracing the gateway. When a traced gateway dials b and b-canary at different addresses, each hop is learned with no rule change: a → gateway, gateway → b, gateway → b-canary. Only L103-105 mentions this. L137-140 says a split by weight cannot be told apart and falls back to instances; that holds for the caller-side rule, not for a traced gateway.

  2. A traced gateway that dials both versions at one address, leaving the split to a router behind it, is not covered. Append the flag to the peer on the gateway's CLIENT span, scoped to the gateway's spans:

    if (peer && meta.service == "<gateway>" && attr("http.request.header.x-canary") == "v2") {
      def peer = peer + "(v2)"
    }
    
  3. The caller-side header rule needs its scope and the gateway guard. It is for a router that emits no spans and is not a listed gateway, such as Istio routing on the header with the sidecars' tracing off. A header canary through an uninstrumented gateway is the unsupported case. Without the guard, L123 turns a listed gateway's peer into ingress:80(v2), and the canary calls skip the gateway node:

    if (peer && !isGateway(peer) && attr("http.request.header.x-canary") == "v2") {
      def peer = peer + "(v2)"
    }
    

    Update TALWorkedExampleTest.theCanaryRecipeOfThePracticesDocLandsEachCallOnItsOwnService the same way, with a call to a listed gateway that still ends on it.

  4. Instrumenting a gateway should say what TAL reads and when the rule changes, with no per-gateway setup:

    • The upstream on the CLIENT span: service.peer.name (read first, so it overrides the address), server.address + server.port, peer.address or url.full.
    • A gateway that records the upstream under another, logically equal attribute: map it in the user's copy of the rule, next to the other peer sources, before the gateway check and prepareNetworkAlias:
      if (!peer && hasAttr("<attribute>")) {
        def peer = attr("<attribute>")
      }
      
    • Which cases need no TAL change: a gateway listed in gateways.yml, and a traced gateway whose upstreams have different addresses.
  5. Routing by path is not an unsupported split. An ingress that sends paths to different services is the ordinary ingress; listed, it draws caller → [ingress] and [ingress] → service for each service. Take it out of L34, L82 and trace-analyzer.md:232, and make the first gateway row read "by host, or by path to distinct services" instead of "one backend behind each address".

TAL itself, the grammar, the runtime and the shipped rule, needs no change for any of these.

wankai123 and others added 3 commits October 29, 2026 13:59
…it changes

- Drop the learned/reported/recentlySent caches: every sampled CLIENT and SERVER
  span sends its half and every matched pair pushes a NetworkAddressAliasSetup.
- NetworkAddressAlias.combine skips the L2 write when the mapping is unchanged and
  was written less than five minutes ago, keeping rows inside the cache's
  ten-minute reload window; a changed mapping is written at the next flush.
- Stamp last_update_time_bucket with the minute OAP receives the setup; the
  time bucket keeps the span's minute for retention.
- Send the CLIENT half once after the rule loop, from the first non-aborted rule
  that named a peer.
- Key OTLPServiceTraffic by Base64(service name) and layer.
- Remove `layer: auto`; document gateways, canaries and gateway instrumentation.
nginx's OpenTelemetry modules report one SERVER span per request and inject
its context upstream, so it is not a gateway that traces itself in the
SpanKind sense. The practices page drops it from that table row, adds a row
and a closing section on pairing both hops from its one span with a rule on
its SERVER span, and the TAL doc says prepareNetworkAlias may run on such a
span.
nginx's OpenTelemetry modules report one SERVER span per request and
inject that span's own context upstream. SpanPairing.clientSeen now
accepts a caller half from a SERVER span as well as from a CLIENT span,
so a rule can send one from that span and both hops are learned. The
shipped rule still prepares only in its CLIENT block.

Docs and comments describe the pairing by caller and callee. The nginx
config keeps only a single upstream address, since every retried
attempt names nginx's span as its parent, and an nginx facing browsers
needs its own sampling decision. A stale tal.md line saying
prepareNetworkAlias checks the alias row is removed.

Tests cover both hops from one span, a root proxy span, and the doc's
rule compiled into a copy of the shipped one. The SpanPairingTest case
asserting that a SERVER span sends no caller half drops that line and
is renamed rootServerConsumerAndProducerSpansSendNoFact.
@wu-sheng

Copy link
Copy Markdown
Member

Pushed: nginx, a proxy that reports one span per request

97c6348c25 (docs) and d47076795b (runtime, tests, wording).

nginx's OpenTelemetry modules, the official ngx_otel_module and the contrib otel_ngx_module, report one SERVER span per request and no CLIENT span for the call to the upstream. With otel_trace_context propagate, that span continues the caller's trace and its own context is injected upstream. The pairing matches a caller half, keyed by the id of the span that sent it, with the callee half of the SERVER span whose parent is that span, so nginx's one span carries both halves:

Caller half Callee half Row
frontend CLIENT, to nginx:80 nginx SERVER frontend->nginx:80 → nginx
the same nginx SERVER span, to its upstream 10.0.3.4:8080 cart SERVER nginx->10.0.3.4:8080 → cart
  • Runtime: SpanPairing.clientSeen accepts a SERVER span's caller half as well as a CLIENT span's. That is the only code change; PairingFact, its wire format, receive, build and AliasRegistrar are unchanged. The shipped otlp-general.yaml is unchanged and still prepares only in its CLIENT block: the nginx rule is a practice, in the user's copy of the rule.

  • Docs: a closing section in trace-analyzer-practices.md, A proxy that reports one span per request:

    • the nginx config: propagate, the upstream on the span with otel_span_attr, kept only when the request tried one server, since every retried attempt's SERVER span names nginx's span as its parent, and a sampling decision of its own for an nginx facing browsers;
    • the TAL rule, in the SERVER block, picking nginx's span by its service name or a resource attribute;
    • the fallback: list nginx in gateways.yml and leave service.layer off its spans.

    The first table row no longer names "an instrumented nginx". tal.md describes prepareNetworkAlias by the pairing rule, the pairing is worded as caller and callee rather than CLIENT and SERVER elsewhere, and a stale tal.md line saying prepareNetworkAlias checks the alias row is removed.

  • Tests: SpanPairingTest.aProxyThatReportsOneSpanPairsBothHopsFromIt and anEdgeProxysRootSpanStillPairsWithItsUpstream, and TALWorkedExampleTest.theOneSpanProxyRecipeLearnsBothHopsFromNginxsServerSpan, which compiles the doc's rule into a copy of the shipped one. rootConsumerAndNonClientSpansSendNoFact loses its line asserting that a SERVER span sends no caller half, and is renamed rootServerConsumerAndProducerSpansSendNoFact.

The nginx behaviour, from source: one span per request, written at the log phase (http_module.cpp#L505-L521), always SERVER (batch_exporter.hpp#L35), its own context injected upstream (#L351, #L389); the contrib module the same (otel_ngx_module.cpp#L528, #L564).

@wu-sheng

Copy link
Copy Markdown
Member

Final conclusion for merging

The review is complete at d47076795b:

  • Network aliases are learned only from strict pairing: a caller half meets the callee half of the SERVER span whose parent is the caller's span. They are keyed by the caller and the address it dialed. KnownServices, the extra pairing caches and the prepare-side skips are gone.
  • NetworkAddressAlias#combine skips unchanged writes, and the update bucket uses OAP's clock.
  • layer: auto is removed.
  • The practices page covers Kubernetes Services, namespaces, proxies, uninstrumented gateways, canaries, sidecars and one-span proxies such as nginx.

The UI side is apache/skywalking-horizon-ui#207. Merge once CI is green. The squash commit message:

Analyze OTLP spans opt-in by service.layer and learn network aliases from strict span pairing (#14170)

TAL is opt-in by layer:
- The shipped tal/otlp-general.yaml (was otlp-default.yaml) declares
  its rules for OTLP_GENERAL (renamed from OTLP_SERVICE). Only spans
  whose resource carries service.layer=OTLP_GENERAL are analyzed; the
  others are stored and searchable, and the OAP logs one INFO line per
  service whose spans carry no layer.
- New OTLP_MESH layer, with no shipped rules, for mesh sidecars.
- No layer: auto. A rule declares a registered layer, or the OAP does
  not start.

The OTLP service catalog keeps the layer:
- OTLPServiceTraffic stores service.layer in otlp_service_layer, one
  row per name and layer, UNDEFINED when absent or unregistered.
  IOTLPTraceQueryDAO#getServiceNames is replaced by getServices(layer),
  filtered in BanyanDB, Elasticsearch and JDBC.
- The stored span carries the same layer, and a TraceQL q on
  resource.service.layer alone is answered from the catalog.

Network aliases come from strict span pairing:
- A caller half, keyed by its span id, meets the callee half of the
  SERVER span whose parent is that span, on the trace's owner node.
  prepareNetworkAlias, buildNetworkAlias and resolveNetworkAlias
  replace the alias statement, resolveAlias and KnownServices.
- The alias is keyed by the caller and the address it dialed,
  frontend->cart:7070 (callerScopedAddress). The topology query folds
  an address node by the caller-scoped row, then by the plain one.
- pairingSamplePercent (default 25) picks the traces paired. An
  address listed in gateways.yml is never aliased and is drawn as a
  VIRTUAL_GATEWAY node (isGateway).
- A SERVER span that passed its own context upstream, as nginx's
  OpenTelemetry modules do, may send a caller half, so a proxy that
  reports one span per request is learned on both hops.

Network address alias writes (agent path too):
- NetworkAddressAlias#combine skips the write of an unchanged mapping
  written less than five minutes ago, and last_update_time_bucket is
  the minute OAP received the setup: a stable alias costs about twelve
  writes an hour instead of about 140.

Docs: tal.md, trace-analyzer.md, otlp-trace.md, traceql-service.md,
configuration-vocabulary.md, and the new
trace-analyzer-practices.md.

@wu-sheng
wu-sheng merged commit cb38b30 into master Oct 29, 2026
949 of 963 checks passed
@wu-sheng
wu-sheng deleted the feature/tal-claimed-spans branch October 29, 2026 09:59
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