001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.server.tracing.otel; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020import static org.apache.juneau.commons.utils.StringUtils.*; 021 022import java.util.*; 023 024import org.apache.juneau.http.tracing.TraceContextCarrier; 025import org.apache.juneau.rest.server.*; 026import org.apache.juneau.rest.server.tracing.*; 027import org.apache.juneau.rest.server.tracing.Scope; 028 029import io.opentelemetry.api.*; 030import io.opentelemetry.api.common.*; 031import io.opentelemetry.api.trace.*; 032import io.opentelemetry.context.*; 033import io.opentelemetry.context.propagation.*; 034 035/** 036 * {@link TracerHook} bridge that opens an OpenTelemetry {@link Span} for every {@code @RestOp} call. 037 * 038 * <h5 class='topic'>Span shape</h5> 039 * 040 * <p> 041 * Each request becomes a single span with kind {@link SpanKind#SERVER}, named after the HTTP method 042 * (e.g. {@code "GET"}, {@code "POST"}) per the OpenTelemetry HTTP semantic conventions. Three OTel 043 * HTTP semantic-convention attributes are populated: 044 * <ul> 045 * <li><b>{@code http.request.method}</b> — uppercased HTTP method. 046 * <li><b>{@code http.response.status_code}</b> — HTTP response status (set right before the 047 * span is closed, so error paths get the framework's resolved status). 048 * <li><b>{@code http.route}</b> — the {@code @RestOp} path template (e.g. 049 * {@code "/users/{id}"}), not the raw concrete URI. Keeps trace cardinality bounded for the same 050 * reason the Micrometer bridge uses the template as its {@code uri} tag. 051 * </ul> 052 * 053 * <p> 054 * On the exception path, the throwable is recorded via {@link Span#recordException(Throwable)}, the 055 * span status is set to {@link StatusCode#ERROR}, and an {@code exception.type} attribute carries the 056 * exception's simple-name. 057 * 058 * <h5 class='topic'>W3C trace context propagation</h5> 059 * 060 * <p> 061 * Incoming {@code traceparent} / {@code tracestate} request headers are extracted via the configured 062 * {@link TextMapPropagator} (default: {@link io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator}) so the server span continues 063 * a caller-supplied distributed trace. Downstream HTTP calls made from inside the handler will pick 064 * up the active span's context automatically when the consumer's outbound HTTP client honors the OTel 065 * {@link Context#current() current context} (the standard OTel client instrumentations do). 066 * 067 * <h5 class='topic'>Wiring</h5> 068 * 069 * <p class='bjava'> 070 * <ja>@Configuration</ja> 071 * <jk>public class</jk> ObservabilityConfig { 072 * 073 * <jc>// Option A: rely on the JVM-wide GlobalOpenTelemetry (recommended).</jc> 074 * <ja>@Bean</ja> 075 * <jk>public</jk> TracerHook tracerHook() { 076 * <jk>return new</jk> OtelTracerHook(); 077 * } 078 * 079 * <jc>// Option B: pass a specific OpenTelemetry instance (e.g. for tests or multi-tenant setups).</jc> 080 * <ja>@Bean</ja> 081 * <jk>public</jk> TracerHook tracerHook(OpenTelemetry <jv>otel</jv>) { 082 * <jk>return new</jk> OtelTracerHook(<jv>otel</jv>); 083 * } 084 * } 085 * </p> 086 * 087 * <h5 class='section'>See Also:</h5><ul> 088 * <li class='jc'>{@link TracerHook} 089 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerObservability">Observability — Micrometer + OpenTelemetry</a> 090 * <li class='link'><a class="doclink" href="https://opentelemetry.io/docs/specs/semconv/http/http-spans/">OpenTelemetry HTTP semantic conventions</a> 091 * <li class='link'><a class="doclink" href="https://www.w3.org/TR/trace-context/">W3C Trace Context</a> 092 * </ul> 093 * 094 * @since 10.0.0 095 */ 096@SuppressWarnings({ 097 "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants 098 "resource" // Span / Scope returned to caller for try-with-resources management; Eclipse JDT @Owning warning is by design. 099}) 100public class OtelTracerHook implements TracerHook { 101 102 /** Default tracer instrumentation scope name. */ 103 public static final String DEFAULT_INSTRUMENTATION_NAME = "org.apache.juneau.rest.server"; 104 105 /** OTel HTTP semantic-convention attribute key {@code http.request.method}. */ 106 public static final AttributeKey<String> ATTR_HTTP_REQUEST_METHOD = AttributeKey.stringKey("http.request.method"); 107 108 /** OTel HTTP semantic-convention attribute key {@code http.response.status_code}. */ 109 public static final AttributeKey<Long> ATTR_HTTP_RESPONSE_STATUS_CODE = AttributeKey.longKey("http.response.status_code"); 110 111 /** OTel HTTP semantic-convention attribute key {@code http.route}. */ 112 public static final AttributeKey<String> ATTR_HTTP_ROUTE = AttributeKey.stringKey("http.route"); 113 114 /** OTel attribute key {@code exception.type} used when recording a thrown exception. */ 115 public static final AttributeKey<String> ATTR_EXCEPTION_TYPE = AttributeKey.stringKey("exception.type"); 116 117 private final Tracer tracer; 118 private final TextMapPropagator propagator; 119 120 /** 121 * Constructor using {@link GlobalOpenTelemetry#get()} as the {@link OpenTelemetry} source. 122 * 123 * <p> 124 * Convenient when the deployment installs a single global {@link OpenTelemetry} (the dominant 125 * pattern with the OTel auto-instrumentation agent). Equivalent to 126 * {@code new OtelTracerHook(GlobalOpenTelemetry.get())}. 127 */ 128 public OtelTracerHook() { 129 this(GlobalOpenTelemetry.get()); 130 } 131 132 /** 133 * Constructor with an explicit {@link OpenTelemetry} instance. 134 * 135 * <p> 136 * The instance is used to obtain both the {@link Tracer} (named 137 * {@value #DEFAULT_INSTRUMENTATION_NAME}) and the {@link TextMapPropagator} used for incoming / 138 * outgoing W3C trace-context header transfer. 139 * 140 * @param openTelemetry The {@link OpenTelemetry} instance. Must not be <jk>null</jk>. 141 */ 142 public OtelTracerHook(OpenTelemetry openTelemetry) { 143 reqnn("openTelemetry", openTelemetry); 144 this.tracer = openTelemetry.getTracer(DEFAULT_INSTRUMENTATION_NAME); 145 this.propagator = openTelemetry.getPropagators().getTextMapPropagator(); 146 } 147 148 /** 149 * Constructor with an explicit {@link Tracer} and {@link TextMapPropagator}. 150 * 151 * <p> 152 * Useful for tests where in-memory tracer / propagator instances are wired directly without going 153 * through a full {@link OpenTelemetry} SDK assembly. 154 * 155 * @param tracer The {@link Tracer} used to build per-request spans. Must not be <jk>null</jk>. 156 * @param propagator The {@link TextMapPropagator} used for incoming / outgoing W3C trace-context 157 * header transfer. Must not be <jk>null</jk>. 158 */ 159 public OtelTracerHook(Tracer tracer, TextMapPropagator propagator) { 160 this.tracer = reqnn("tracer", tracer); 161 this.propagator = reqnn("propagator", propagator); 162 } 163 164 @Override /* TracerHook */ 165 public Scope startSpan(RestRequest request) { 166 return startSpan(request, null, TraceOperation.DEFAULT); 167 } 168 169 @Override /* TracerHook */ 170 public Scope startSpan(RestRequest request, TraceContextCarrier carrier, TraceOperation operation) { 171 Context extracted = extractContext(request, carrier); 172 173 String httpMethod = defaultIfBlank(request.getMethod(), "UNKNOWN"); 174 String route = resolveRoute(request); 175 String spanName = defaultIfBlank(operation.getSpanName(), httpMethod); 176 177 var spanBuilder = tracer.spanBuilder(spanName) 178 .setSpanKind(SpanKind.SERVER) 179 .setParent(extracted) 180 .setAttribute(ATTR_HTTP_REQUEST_METHOD, httpMethod); 181 if (! route.isEmpty()) 182 spanBuilder.setAttribute(ATTR_HTTP_ROUTE, route); 183 operation.getAttributes().forEach((k, v) -> spanBuilder.setAttribute(AttributeKey.stringKey(k), v)); 184 185 Span span = spanBuilder.startSpan(); 186 Context spanContext = extracted.with(span); 187 188 // Render the W3C trace-context headers (and baggage, when a baggage propagator is configured) 189 // from the server-started span's context (the only point where it's reliably active) and stash 190 // them as request attributes for TraceContextResponseProcessor / a dated MCP result to read 191 // later. The configured propagator only injects traceparent when the span context is valid, and 192 // tracestate/baggage only when non-empty, so both guards are handled here for free. 193 stashTraceContext(request, spanContext); 194 195 io.opentelemetry.context.Scope otelScope = spanContext.makeCurrent(); 196 197 return new OtelScope(span, otelScope); 198 } 199 200 /** 201 * Builds the effective parent {@link Context} for a new server span, giving a non-HTTP 202 * {@link TraceContextCarrier} — recognized by an active {@code TraceContextExtractor} among 203 * the resolved operation arguments (for example an MCP request's {@code params._meta}) — 204 * precedence over HTTP request headers. 205 * 206 * <p> 207 * Extraction runs in up to two passes against the same configured {@link #propagator}: first 208 * against ordinary HTTP headers (via {@link RestRequestTextMapGetter}), then — only when 209 * <c>carrier</c> is non-<jk>null</jk> — a second pass against <c>carrier</c> (via 210 * {@link TraceContextCarrierTextMapGetter}), seeded with the first pass's result. Per the 211 * {@link TextMapPropagator} contract, extraction that finds nothing usable returns the given 212 * {@link Context} unmodified, so an explicit carrier value wins while an absent or invalid one 213 * falls back to whatever the HTTP pass already produced. This applies independently to the 214 * {@code traceparent}/{@code tracestate} pair (extracted together as one {@code SpanContext}) and 215 * to {@code baggage} (extracted separately whenever the configured propagator includes a baggage 216 * propagator). 217 * 218 * @param request The in-flight request; source of the HTTP-header fallback. Never <jk>null</jk>. 219 * @param carrier The extractor-recognized carrier, or <jk>null</jk> if none was recognized (HTTP 220 * headers only). 221 * @return The effective parent {@link Context}. Never <jk>null</jk>. 222 */ 223 private Context extractContext(RestRequest request, TraceContextCarrier carrier) { 224 Context httpContext = propagator.extract(Context.current(), request, RestRequestTextMapGetter.INSTANCE); 225 if (carrier == null) 226 return httpContext; 227 return propagator.extract(httpContext, carrier, TraceContextCarrierTextMapGetter.INSTANCE); 228 } 229 230 @Override /* TracerHook */ 231 public Scope startSpan(String spanName) { 232 // Custom (non-request) observation: open an INTERNAL span parented to the current context 233 // (so it nests under an active request span when one exists), with no HTTP attributes. 234 Span span = tracer.spanBuilder(spanName) 235 .setSpanKind(SpanKind.INTERNAL) 236 .setParent(Context.current()) 237 .startSpan(); 238 io.opentelemetry.context.Scope otelScope = Context.current().with(span).makeCurrent(); 239 return new OtelScope(span, otelScope); 240 } 241 242 private void stashTraceContext(RestRequest request, Context spanContext) { 243 var carrier = new HashMap<String,String>(4); 244 propagator.inject(spanContext, carrier, MAP_SETTER); 245 var traceparent = carrier.get("traceparent"); 246 if (nn(traceparent)) 247 request.setAttribute(TraceContextResponseProcessor.ATTR_TRACEPARENT, traceparent); 248 var tracestate = carrier.get("tracestate"); 249 if (nn(tracestate) && ! tracestate.isEmpty()) 250 request.setAttribute(TraceContextResponseProcessor.ATTR_TRACESTATE, tracestate); 251 var baggage = carrier.get("baggage"); 252 if (nn(baggage) && ! baggage.isEmpty()) 253 request.setAttribute(TraceContextResponseProcessor.ATTR_BAGGAGE, baggage); 254 } 255 256 private static final TextMapSetter<HashMap<String,String>> MAP_SETTER = (carrier, key, value) -> { 257 if (nn(carrier)) 258 carrier.put(key, value); 259 }; 260 261 @Override /* TracerHook */ 262 public void inject(TraceContextCarrier carrier) { 263 propagator.inject(Context.current(), carrier, TraceContextCarrierTextMapSetter.INSTANCE); 264 } 265 266 /** 267 * Returns the {@link Tracer} this hook publishes spans to. 268 * 269 * @return The {@link Tracer}. 270 */ 271 public Tracer getTracer() { return tracer; } 272 273 /** 274 * Returns the {@link TextMapPropagator} used to extract W3C trace-context headers from 275 * incoming requests. 276 * 277 * @return The {@link TextMapPropagator}. 278 */ 279 public TextMapPropagator getPropagator() { return propagator; } 280 281 private static String resolveRoute(RestRequest request) { 282 try { 283 var opCtx = request.getOpContext(); 284 return opCtx == null ? "" : defaultIfBlank(opCtx.getPathPattern(), ""); 285 } catch (RuntimeException e) { 286 // Defensive: opCtx.getPathPattern() reaches into the path-matcher array; protect against NPE / array bounds in odd routing setups. 287 return ""; 288 } 289 } 290 291 private static String defaultIfBlank(String value, String fallback) { 292 return isEmpty(value) ? fallback : value; 293 } 294 295 private static final class OtelScope implements Scope { 296 297 private final Span span; 298 private final io.opentelemetry.context.Scope scope; 299 300 OtelScope(Span span, io.opentelemetry.context.Scope scope) { 301 this.span = span; 302 this.scope = scope; 303 } 304 305 @Override /* Scope */ 306 public void setStatusCode(int statusCode) { 307 span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, (long) statusCode); 308 if (statusCode >= 500) 309 span.setStatus(StatusCode.ERROR); 310 } 311 312 @Override /* Scope */ 313 public void setError(Throwable error) { 314 if (nn(error)) { 315 span.recordException(error); 316 span.setAttribute(ATTR_EXCEPTION_TYPE, cns(error)); 317 span.setStatus(StatusCode.ERROR); 318 } 319 } 320 321 @Override /* Scope */ 322 public void close() { 323 try { 324 scope.close(); 325 } finally { 326 span.end(); 327 } 328 } 329 330 @Override /* Scope */ 331 public void recordRpcError(int code, String message) { 332 span.setAttribute(TraceOperation.ATTR_RPC_RESPONSE_STATUS_CODE, String.valueOf(code)); 333 span.setAttribute(TraceOperation.ATTR_ERROR_TYPE, errorType(code)); 334 span.setStatus(StatusCode.ERROR, message == null ? "" : message); 335 } 336 337 /** 338 * Maps a JSON-RPC 2.0 error code to a low-cardinality {@code error.type} category. 339 * 340 * <p> 341 * The five codes reserved by the JSON-RPC 2.0 specification map to their exact category name; 342 * any code in the specification's reserved-for-implementation-defined-server-errors range 343 * ({@code -32000} to {@code -32099}, inclusive) maps to the generic {@code "server_error"} 344 * category. An application-defined code outside every reserved range falls back to its own 345 * decimal string so the attribute stays populated (if not maximally low-cardinality) rather than 346 * silently empty. 347 * 348 * @param code The JSON-RPC error code (e.g. {@code -32601}). 349 * @return The {@code error.type} category. Never <jk>null</jk>. 350 */ 351 private static String errorType(int code) { 352 switch (code) { 353 case -32700: return "parse_error"; 354 case -32600: return "invalid_request"; 355 case -32601: return "method_not_found"; 356 case -32602: return "invalid_params"; 357 case -32603: return "internal_error"; 358 default: 359 return (code <= -32000 && code >= -32099) ? "server_error" : String.valueOf(code); 360 } 361 } 362 } 363}