OpenTelemetry Trace Analysis Practices
The trace analysis names a peer that a CLIENT span reaches by address, cart:7070 or
172.19.0.19:8080, after the service that answered, by pairing the caller and the callee of one call: the callee’s
SERVER span names the caller’s span as its parent. The alias it learns is keyed by the caller and the address it dialed,
frontend->cart:7070. The mechanism is described under Address-named
peers. This page shows how it behaves in common deployments,
and what to change when it does not draw what you expect.
Callers dial a Kubernetes Service
frontend dials cart:7070, the Kubernetes Service in front of the cart pods, which report
service.name=cart. The first calls are drawn as frontend → cart:7070. Once one of them is paired, after about
four calls at the default pairingSamplePercent of 25, the row frontend->cart:7070 → cart is written, and once
it has been stored and reloaded, about 35 seconds later, the edge lands on cart. The calls recorded before then keep their
metrics under the frontend → cart:7070 relation; the topology folds that node onto cart.
A second caller, checkout, dialing the same cart:7070, is learned on its own: its first calls are drawn to the
address too. The name cart is never guessed from the host, so a Service named differently from the pods'
service.name works the same way.
The same application in two namespaces
With OTLP service grouping by namespace, shop::frontend and outlet::frontend both dial
cart:7070, each reaching the cart of its own namespace. The rows are shop::frontend->cart:7070 → shop::cart and
outlet::frontend->cart:7070 → outlet::cart: the caller in the key keeps them apart, whatever the grouping attribute
is.
Proxies, gateways and canaries: which setup
| In front of the callee | Do | Topology |
|---|---|---|
| A proxy or gateway that traces itself with a SERVER and a CLIENT span per request, as Envoy | give its spans service.layer=OTLP_GENERAL |
caller → gateway → backend, each hop learned from its own spans, see A proxy that emits spans and Instrumenting a gateway |
| A proxy that reports one span per request, as nginx with its OpenTelemetry module | propagate the context, put the upstream on its span, and add a rule on its SERVER span | caller → nginx → backend, see A proxy that reports one span per request |
| A gateway that does not trace, routing by host, or by path to distinct services | list it in uninstrumented gateways | caller → [gateway] → service, an edge per service, see A load balancer or gateway that does not trace |
| A gateway that does not trace and splits one address between versions, by blue/green, weight or header | not supported: instrument the gateway | as for a gateway that traces itself |
A canary behind a traced gateway that dials b and b-canary at their own addresses |
nothing | gateway → b and gateway → b-canary, see A canary |
| A canary behind a traced gateway that dials both at one address, a router behind it splitting them | a rule on the gateway’s CLIENT spans | gateway → b and gateway → b-canary, see A canary |
| A canary chosen by a header, through a router that emits no spans and is not listed, as Istio with the sidecars’ tracing off | the header rule on the callers’ CLIENT spans | a → b and a → b-canary, see A canary |
A proxy that emits spans
The OpenTelemetry Demo’s browser calls frontend-proxy:8080, an Envoy that traces itself, which forwards to
frontend at 172.19.0.19:8080 and keeps the Host header, so frontend’s SERVER span reports
server.address=frontend-proxy. Each hop is learned from its own two spans:
| Caller span | Callee span | Row |
|---|---|---|
frontend-web CLIENT to frontend-proxy:8080 |
frontend-proxy SERVER |
frontend-web->frontend-proxy:8080 → frontend-proxy |
frontend-proxy CLIENT to 172.19.0.19:8080 |
frontend SERVER |
frontend-proxy->172.19.0.19:8080 → frontend |
The address frontend reports is never used, so the proxy keeps its own node. The proxy’s spans need
service.layer=OTLP_GENERAL too: an unanalyzed proxy sends no pairing fact, and its callers stay address-named.
A load balancer or gateway that does not trace
An ingress, a cloud load balancer or an nginx without tracing emits no spans, so the callee’s SERVER span names the
caller’s CLIENT span as its parent while the caller dialed the gateway’s address. Without more, the pair would name
the gateway’s address after the service behind it, and every service behind it would take the address in turn. List
the gateway in uninstrumented gateways: its address is never aliased, the caller’s edge
ends on a VIRTUAL_GATEWAY node, and the callee’s edge starts from it when the gateway passes its callers’ Host
through.
List the address the callers dial, as their CLIENT spans report it, in config/gateways.yml:
gateways:
- name: shop-ingress
instances:
- host: shop-ingress.ingress.svc # as the callers' server.address or peer.address names it
port: 80
- The match is exact: a caller that dials
shop-ingressand another that dialsshop-ingress.ingress.svcneed both listed. A host without a port is matched at port 80. - The callee’s edge from the gateway is drawn from the address the callee reports it was dialed at, its
server.addressandserver.port, which an HTTP server takes from theHostheader. Keep the callers’Hoston the way through; for nginx,proxy_set_header Host $host;, sinceproxy_passotherwise sends the upstream’s own name. A gateway that rewritesHoststill ends its callers’ edges, but the services behind it draw no edge from it. AHostwithout a port, as$hostpasses it, is read as port 80, the port the file assumes, so a gateway listening elsewhere, such as HTTPS on 443, draws the edge to the services behind it only when theHostit passes carries the port. - The file belongs to the agent analyzer; with the
agent-analyzermodule not loaded, no address is a gateway. See uninstrumented gateways for the format and for changing it at runtime.
A listed ingress that routes by host, or by path to distinct services, is the ordinary case: the callers’ edges end on
the gateway, and each service behind it draws its own edge from the gateway when the Host and the trace context are
passed through. Such a gateway left out of the file cannot work: each service answers calls its callers made to the
same gateway address, so the pairs hand that address to one service after another.
A gateway that does not trace and splits one address between versions of a service, by a blue/green switch, by weight or by a header, is not supported. Instrument the gateway, so it dials each version at its own address, see A canary.
Instrumenting a gateway
A gateway that traces itself is analyzed like any service, and each hop through it is learned from its own two spans. Whatever the gateway, check that:
- It exports its spans over OTLP, with
service.layer=OTLP_GENERALon their resource. If the gateway cannot set resource attributes, insert it in the OpenTelemetry Collector it exports through, with aresourceprocessor that usesaction: insert, so a service that set its own layer keeps it. Without the layer its spans are stored but not analyzed, it sends no pairing fact, and its callers stay address-named. - It continues the caller’s trace and passes the trace context to the backend, as W3C
traceparent. The pairing matches a SERVER span with the caller’s span that is its parent; a gateway that starts a new trace leaves the hop from its caller unpaired, and one that does not forward the context leaves the hop to the backend unpaired. - It reports a SERVER span for the request it receives and a CLIENT span for each call it makes to a backend. The SERVER span pairs with the caller; the CLIENT span pairs with the backend. A gateway that reports one span per request pairs only one of the two hops, unless a rule also sends a caller half from that span, see A proxy that reports one span per request.
- Its CLIENT span names the backend it dialed. The shipped rule reads, in this order:
service.peer.name(orpeer.service), which overrides the address;server.addressandserver.port;peer.address, as Envoy reports it; then the host and port ofurl.full. That peer keys the row, so a blue and a green backend dialed at their own addresses are two rows,gateway->10.0.1.5:8080andgateway->10.0.2.7:8080, each naming its own service.
A gateway that records its upstream under another attribute, logically the same as these, needs one line in your
copy of otlp-general.yaml, next to the other peer sources in the CLIENT block, before the gateway check and
prepareNetworkAlias:
if (!peer && hasAttr("<attribute>")) {
def peer = attr("<attribute>")
}
Nothing else in TAL changes per gateway. A gateway listed in gateways.yml needs no rule change, and neither does a
traced gateway whose upstreams have distinct addresses; only a traced gateway that dials two versions at one address
does, see A canary.
The OpenTelemetry Demo’s frontend-proxy, an Envoy that traces itself, does all four, see A proxy that emits
spans. Its spans get service.layer from the demo’s Collector.
Do not also list an instrumented gateway in gateways.yml: its address would never be aliased, and its callers'
edges would end on a VIRTUAL_GATEWAY node instead of on the gateway’s own service.
A canary
b has a canary, b-canary, which reports service.name=b-canary; requests carrying x-canary: v2 go to it, the
others to b. How the topology tells the two apart depends on what routes the requests.
A traced gateway that dials each version at its own address needs nothing: gateway->10.0.1.5:8080 → b and
gateway->10.0.2.7:8080 → b-canary are two rows learned from the gateway’s own spans, so the topology reads
a → gateway, gateway → b and gateway → b-canary, whether the split is by header, weight or blue/green. This is
the setup to prefer: instrument the gateway.
A traced gateway that dials both versions at one address, leaving the split to a router behind it, makes both
pairs write one row, and the edge flips between b and b-canary. Make the version part of the peer on the gateway’s
own CLIENT spans, in the CLIENT block of your copy of otlp-general.yaml, after the peer is named and before
prepareNetworkAlias:
if (peer && meta.service == "<gateway>" && attr("http.request.header.x-canary") == "v2") {
def peer = peer + "(v2)"
}
A router that emits no spans and is not a listed gateway, such as Istio routing on the header with the sidecars' tracing off, leaves the callers’ CLIENT spans as the only ones that dialed the address. Put the version on their peer:
if (peer && !isGateway(peer) && attr("http.request.header.x-canary") == "v2") {
def peer = peer + "(v2)"
}
The canary’s calls are then a->b:8080(v2) → b-canary, the others a->b:8080 → b, and each edge lands on its own
service. The SERVER block, buildNetworkAlias and the topology fold stay as they are, since the version travels in
the peer the caller prepared. A header canary through a gateway that does not trace is the unsupported case above.
!isGateway(peer)keeps a call to a gateway listed ingateways.ymlas it is:ingress:80(v2)would no longer match the file, so the canary’s calls would skip the gateway node the others end on.- The header has to be on the CLIENT span the rule reads, which the SDKs capture only when asked, for example
OTEL_INSTRUMENTATION_HTTP_CLIENT_CAPTURE_REQUEST_HEADERS=x-canaryfor the OpenTelemetry Java agent. The attribute ishttp.request.header.x-canary. - Match the values you route on. Appending any header value as it comes makes a new peer, and a new pair to learn, per value.
- With a rule on the CLIENT span, a split by weight, or by anything that span does not carry, such as a header the
router sets itself, cannot be told apart. Trace the gateway so it dials the versions at their own addresses, or
report the canary under the same
service.namewith its ownservice.instance.idorservice.version, so its calls areb’s and the instances tell the versions apart. - Rules for one layer all run, so list your file in
talFiles(SW_TRACE_TAL_FILES) in place ofotlp-general, not beside it, or every span is counted twice.
Service mesh sidecars that trace
When the applications and their sidecars both export spans, the trace reads a CLIENT, a’s sidecar, b’s sidecar,
b SERVER. b’s SERVER span names its sidecar’s span as its parent, not a’s CLIENT span, so no pair of analyzed
spans meets and the edge stays address-named. Labelling the sidecars’ spans service.layer=OTLP_MESH keeps them out
of the analysis but not out of the trace, so it does not help here. Trace each hop once: either the applications,
with the sidecars’ tracing off and the trace context passed through, or the mesh alone, whose topology the service
mesh analysis draws from access logs.
Agent-instrumented callers
A SkyWalking agent calling an OTLP service by address writes no caller-scoped row, and the OTLP service registers no plain-address row for it, so that edge stays drawn to the address. An OTLP caller of an agent-instrumented service is folded at query time by the plain row the agent registers.
A proxy that reports one span per request
The pairing matches the two halves of one call: a caller half, keyed by the id of the span that sent it, and a callee
half from a SERVER span, keyed by the id of its parent. A proxy that follows the OpenTelemetry trace API reports a
SERVER span for the request it receives and a CLIENT span for its call to the upstream, since a SERVER span does not
describe an outgoing call
(SpanKind),
so each of its hops pairs through its own span, see Instrumenting a gateway. nginx’s
OpenTelemetry modules, the official ngx_otel_module and the contrib otel_ngx_module, report one SERVER span per
request. With otel_trace_context propagate, that span continues the caller’s trace, and nginx injects the span’s own
context into the call to the upstream. Both halves then come from that one span: its callee half, keyed by its parent,
pairs with the caller’s CLIENT span, and a caller half sent from it, keyed by its own id, pairs with the upstream’s
SERVER span, whose parent it is. The shipped rule sends the callee half; a rule that also sends the caller half,
naming the upstream, learns both hops:
| 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 |
The topology reads frontend → nginx → cart. Each upstream keys its own row, so a blue/green or weight split inside
nginx is drawn as for any traced gateway.
On nginx, continue the trace and pass it on, follow the caller’s sampling decision, put the upstream on the span, and give the span the layer:
otel_service_name nginx;
otel_resource_attr service.layer OTLP_GENERAL;
otel_trace $otel_parent_sampled;
otel_trace_context propagate;
# Only a request answered by the one server it tried names its upstream; see the retries below.
map $upstream_addr $otel_upstream {
"~^(?<otel_single_upstream>[^,\s]+)$" $otel_single_upstream;
default "";
}
otel_span_attr upstream.address $otel_upstream;
otel_span_attris evaluated when the span is written, after the upstream answered, so$upstream_addris known. The contrib module takes the same value withopentelemetry_attribute upstream.address $otel_upstream;, and passes the context on withopentelemetry_propagate;.- A retried request,
proxy_next_upstream, lists every server it tried,10.0.3.4:8080, 10.0.3.5:8080, and each attempt’s SERVER span names nginx’s span as its parent, so the pair cannot tell which attempt the last address belongs to: with a blue and a green server in one upstream, it could name the green address after blue. The map keeps such a request’s upstream empty, so it draws no call and its attempts pair with nothing. $otel_parent_sampledfollows the caller’s sampling decision, so a request that arrives with no trace context, as at an nginx that faces browsers, is not recorded. Give such an nginx its own decision,otel_trace on;or a ratio as in the module’s README. Its span then starts the trace, sends no callee half, and still pairs with its upstream.- With
propagateorinjectbut withoutotel_trace, nginx records no span and passes the context on as not sampled, so a service behind it whose SDK samples by its parent records nothing either. upstream.addressis a name of your choice, not a semantic convention. Pick one that no application sets.
In TAL, in the SERVER block of your copy of otlp-general.yaml, pick nginx’s SERVER span and send a caller half
from it as well:
// nginx's SERVER span, picked by the service name `otel_service_name` set. It also made the call to the upstream.
if (meta.kind == "SERVER" && meta.service == "nginx") {
def upstream = attr("upstream.address")
if (upstream) {
prepareNetworkAlias upstream
def upstream = resolveNetworkAlias(callerScopedAddress(meta.service, upstream))
def proxied = serviceRelation(CLIENT, meta.service, meta.layer, upstream, "OTLP_GENERAL", component("http"))
metrics { name "service_relation_client_cpm"; scope proxied; function cpm; value 1 }
metrics { name "service_relation_client_resp_time"; scope proxied; function avg; value meta.latency }
metrics { name "service_relation_client_call_sla"; scope proxied; function percent
match ok }
}
}
-
Instead of the service name, any tag only nginx’s spans carry can pick them, such as a resource attribute set in the nginx config:
otel_resource_attr proxy.type nginx;if (meta.kind == "SERVER" && resource("proxy.type") == "nginx") {With OTLP service grouping,
meta.serviceis the grouped name, such asshop::nginx, so compare that, or use the tag. On any other service’s SERVER span, the block would draw a call that was never made. -
A request nginx answers itself, a redirect or a static file, has no upstream, so it draws no call.
-
The rest of the SERVER block runs as for any service, so the callers’ hop to nginx is learned too.
-
The relation’s latency is the whole request at nginx, its own time included.
-
Do not also list this nginx in
gateways.yml.
Without the rule, treat nginx as a gateway that does not trace:
list it in gateways.yml, keep the callers’ Host, and leave service.layer off its spans, so they stay in the
trace but out of the analysis. The topology then reads caller → [nginx] → service, as long as the services behind it
report a SERVER span with a parent: the trace context has to reach them, and, where they sample by their parent,
arrive sampled. With propagate, an nginx that is analyzed, but neither listed nor given the rule, learns its
callers’ hop and draws nothing to the services behind it, whose SERVER spans pair with nothing.