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> &mdash; uppercased HTTP method.
046 *    <li><b>{@code http.response.status_code}</b> &mdash; 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> &mdash; 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} &mdash; recognized by an active {@code TraceContextExtractor} among
203    * the resolved operation arguments (for example an MCP request's {@code params._meta}) &mdash;
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 &mdash; only when
209    * <c>carrier</c> is non-<jk>null</jk> &mdash; 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}