zipkin2.server.internal.ui.ZipkinUiConfiguration Maven / Gradle / Ivy
/*
 * Copyright 2015-2019 The OpenZipkin Authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License. You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package zipkin2.server.internal.ui;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonGenerator;
import com.linecorp.armeria.common.HttpData;
import com.linecorp.armeria.common.HttpHeaderNames;
import com.linecorp.armeria.common.HttpRequest;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.common.MediaType;
import com.linecorp.armeria.common.ServerCacheControl;
import com.linecorp.armeria.common.ServerCacheControlBuilder;
import com.linecorp.armeria.server.AbstractHttpService;
import com.linecorp.armeria.server.HttpService;
import com.linecorp.armeria.server.RedirectService;
import com.linecorp.armeria.server.ServiceRequestContext;
import com.linecorp.armeria.server.file.HttpFileBuilder;
import com.linecorp.armeria.server.file.HttpFileServiceBuilder;
import com.linecorp.armeria.spring.ArmeriaServerConfigurator;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.config.MeterFilter;
import io.netty.handler.codec.http.cookie.Cookie;
import io.netty.handler.codec.http.cookie.ServerCookieDecoder;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.Resource;
import org.springframework.util.StreamUtils;
import zipkin2.server.internal.JsonUtil;
import static java.nio.charset.StandardCharsets.UTF_8;
import static zipkin2.server.internal.ui.ZipkinUiProperties.DEFAULT_BASEPATH;
/**
 * Zipkin-UI is a single-page application mounted at /zipkin. For simplicity, assume paths mentioned
 * below are relative to that. For example, the UI reads config.json, from the absolute path
 * /zipkin/config.json
 *
 * When looking at a trace, the browser is sent to the path "/traces/{id}". For the single-page
 * app to serve that route, the server needs to forward the request to "/index.html". The same
 * forwarding applies to "/dependencies" and any other routes the UI controls.
 *
 * 
Under the scenes the JavaScript code looks at {@code window.location} to figure out what the
 * UI should do. This is handled by a route api defined in the crossroads library.
 *
 * 
Caching
 * This includes a hard-coded cache policy, consistent with zipkin-scala.
 * 
 *   - 1 minute for index.html
 
 *   - 10 minute for /config.json
 
 *   - 365 days for hashed resources (ex /app-e12b3bbb7e5a572f270d.min.js)
 
 * 
 * Since index.html links to hashed resource names, any change to it will orphan old resources.
 * That's why hashed resource age can be 365 days.
 */
@EnableConfigurationProperties({ZipkinUiProperties.class, CompressionProperties.class})
@ConditionalOnProperty(name = "zipkin.ui.enabled", matchIfMissing = true)
public class ZipkinUiConfiguration {
  static final JsonFactory JSON_FACTORY = new JsonFactory();
  @Autowired ZipkinUiProperties ui;
  @Value("classpath:zipkin-ui/index.html") Resource classicIndexHtml;
  @Value("classpath:zipkin-lens/index.html") Resource lensIndexHtml;
  @Bean HttpService indexService() throws IOException {
    HttpService lensIndex = maybeIndexService(ui.getBasepath(), lensIndexHtml);
    HttpService classicIndex = maybeIndexService(ui.getBasepath(), classicIndexHtml);
    if (lensIndex != null && classicIndex != null) {
      // In both our old and new UI, assets have hashes in the filenames (generated by webpack).
      // This allows us to host both simultaneously without conflict as long as we change the index
      // file to point to the correct files.
      return new IndexSwitchingService(classicIndex, lensIndex);
    }
    if (lensIndex != null) return lensIndex;
    throw new BeanCreationException("Could not load Lens UI from " + lensIndexHtml);
  }
  @Bean ArmeriaServerConfigurator uiServerConfigurator(
    HttpService indexService,
    Optional meterRegistry
  ) throws IOException {
    ServerCacheControl maxAgeYear =
      new ServerCacheControlBuilder().maxAgeSeconds(TimeUnit.DAYS.toSeconds(365)).build();
    HttpService uiFileService;
    if (indexService instanceof IndexSwitchingService) {
      // In both our old and new UI, assets have hashes in the filenames (generated by webpack).
      // This allows us to host both simultaneously without conflict as long as we change the index
      // file to point to the correct files.
      uiFileService =
        HttpFileServiceBuilder.forClassPath("zipkin-ui").cacheControl(maxAgeYear).build()
          .orElse(
            HttpFileServiceBuilder.forClassPath("zipkin-lens").cacheControl(maxAgeYear).build());
    } else {
      uiFileService =
        HttpFileServiceBuilder.forClassPath("zipkin-lens").cacheControl(maxAgeYear).build();
    }
    String config = writeConfig(ui);
    return sb -> {
      sb.service("/zipkin/config.json", HttpFileBuilder.of(HttpData.ofUtf8(config))
        .cacheControl(new ServerCacheControlBuilder().maxAgeSeconds(600).build())
        .contentType(MediaType.JSON_UTF_8)
        .build()
        .asService());
      sb.serviceUnder("/zipkin/", uiFileService);
      // TODO This approach requires maintenance when new UI routes are added. Change to the following:
      // If the path is a a file w/an extension, treat normally.
      // Otherwise instead of returning 404, forward to the index.
      // See https://github.com/twitter/finatra/blob/458c6b639c3afb4e29873d123125eeeb2b02e2cd/http/src/main/scala/com/twitter/finatra/http/response/ResponseBuilder.scala#L321
      sb.service("/zipkin/", indexService)
        .service("/zipkin/index.html", indexService)
        .service("/zipkin/traces/{id}", indexService)
        .service("/zipkin/dependency", indexService)
        .service("/zipkin/traceViewer", indexService);
      sb.service("/favicon.ico", new RedirectService(HttpStatus.FOUND, "/zipkin/favicon.ico"))
        .service("/", new RedirectService(HttpStatus.FOUND, "/zipkin/"))
        .service("/zipkin", new RedirectService(HttpStatus.FOUND, "/zipkin/"));
      // don't add metrics for favicon
      meterRegistry.ifPresent(m -> m.config().meterFilter(MeterFilter.deny(id -> {
        String uri = id.getTag("uri");
        return uri != null && uri.startsWith("/favicon.ico");
      })));
    };
  }
  static class IndexSwitchingService extends AbstractHttpService {
    final HttpService legacyIndex;
    final HttpService lensIndex;
    IndexSwitchingService(HttpService legacyIndex, HttpService lensIndex) {
      this.legacyIndex = legacyIndex;
      this.lensIndex = lensIndex;
    }
    @Override
    public HttpResponse doGet(ServiceRequestContext ctx, HttpRequest req) throws Exception {
      Set cookies = ServerCookieDecoder.LAX.decode(
        req.headers().get(HttpHeaderNames.COOKIE, ""));
      for (Cookie cookie : cookies) {
        if (cookie.name().equals("lens") && Boolean.parseBoolean(cookie.value())) {
          return lensIndex.serve(ctx, req);
        }
      }
      return legacyIndex.serve(ctx, req);
    }
  }
  // This writes the following config used in zipkin-classic (not yet in zipkin-lens)
  //
  // environment: '',
  // suggestLens: false,
  // queryLimit: 10,
  // defaultLookback: 15 * 60 * 1000, // 15 minutes
  // searchEnabled: true,
  // dependency: {
  //   lowErrorRate: 0.5, // 50% of calls in error turns line yellow
  //   highErrorRate: 0.75 // 75% of calls in error turns line red
  // }
  static String writeConfig(ZipkinUiProperties ui) throws IOException {
    StringWriter writer = new StringWriter();
    try (JsonGenerator generator = JsonUtil.createGenerator(writer)) {
      generator.useDefaultPrettyPrinter();
      generator.writeStartObject();
      generator.writeStringField("environment", ui.getEnvironment());
      generator.writeBooleanField("suggestLens", ui.isSuggestLens());
      generator.writeNumberField("queryLimit", ui.getQueryLimit());
      generator.writeNumberField("defaultLookback", ui.getDefaultLookback());
      generator.writeBooleanField("searchEnabled", ui.isSearchEnabled());
      generator.writeObjectFieldStart("dependency");
      generator.writeNumberField("lowErrorRate", ui.getDependency().getLowErrorRate());
      generator.writeNumberField("highErrorRate", ui.getDependency().getHighErrorRate());
      generator.writeEndObject(); // .dependency
      generator.writeEndObject(); // .
    }
    return writer.toString();
  }
  static HttpService maybeIndexService(String basePath, Resource resource) throws IOException {
    String maybeContent = maybeResource(basePath, resource);
    if (maybeContent == null) return null;
    ServerCacheControl maxAgeMinute = new ServerCacheControlBuilder().maxAgeSeconds(60).build();
    return HttpFileBuilder.of(HttpData.ofUtf8(maybeContent))
      .contentType(MediaType.HTML_UTF_8).cacheControl(maxAgeMinute)
      .build().asService();
  }
  static String maybeResource(String basePath, Resource resource) throws IOException {
    if (!resource.isReadable()) return null;
    try (InputStream stream = resource.getInputStream()) {
      String content = StreamUtils.copyToString(stream, UTF_8);
      if (DEFAULT_BASEPATH.equals(basePath)) return content;
      String baseTagValue = "/".equals(basePath) ? "/" : basePath + "/";
      return content.replaceAll(
        "base href=\"[^\"]+\"", "base href=\"" + baseTagValue + "\""
      );
    }
  }
}
      © 2015 - 2025 Weber Informatics LLC | Privacy Policy