package org.stagemonitor.web; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.stagemonitor.core.CorePlugin; import org.stagemonitor.core.Stagemonitor; import org.stagemonitor.core.StagemonitorPlugin; import org.stagemonitor.configuration.ConfigurationRegistry; import org.stagemonitor.configuration.ConfigurationOption; import org.stagemonitor.configuration.converter.SetValueConverter; import org.stagemonitor.core.elasticsearch.ElasticsearchClient; import org.stagemonitor.core.grafana.GrafanaClient; import org.stagemonitor.core.util.ClassUtils; import org.stagemonitor.util.StringUtils; import org.stagemonitor.web.configuration.ConfigurationServlet; import org.stagemonitor.web.init.ServletContainerInitializerUtil; import org.stagemonitor.web.metrics.StagemonitorMetricsServlet; import org.stagemonitor.web.monitor.MonitoredHttpRequest; import org.stagemonitor.web.monitor.filter.HttpRequestMonitorFilter; import org.stagemonitor.web.monitor.filter.StagemonitorSecurityFilter; import org.stagemonitor.web.monitor.rum.RumServlet; import org.stagemonitor.web.monitor.servlet.StagemonitorFileServlet; import org.stagemonitor.web.monitor.widget.SpanServlet; import org.stagemonitor.web.monitor.widget.WidgetServlet; import org.stagemonitor.web.session.SessionCounter; import java.util.Arrays; import java.util.Collection; import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; import javax.servlet.DispatcherType; import javax.servlet.FilterRegistration; import javax.servlet.ServletContainerInitializer; import javax.servlet.ServletContext; import javax.servlet.ServletRegistration; import javax.servlet.http.HttpServletRequest; import static org.stagemonitor.core.pool.MBeanPooledResource.tomcatThreadPools; import static org.stagemonitor.core.pool.PooledResourceMetricsRegisterer.registerPooledResources; public class WebPlugin extends StagemonitorPlugin implements ServletContainerInitializer { public static final String STAGEMONITOR_SHOW_WIDGET = "X-Stagemonitor-Show-Widget"; private static final String WEB_PLUGIN = "Web Plugin"; private static final Logger logger = LoggerFactory.getLogger(WebPlugin.class); static { Stagemonitor.init(); } private final ConfigurationOption<Collection<Pattern>> requestParamsConfidential = ConfigurationOption.regexListOption() .key("stagemonitor.requestmonitor.http.requestparams.confidential.regex") .dynamic(true) .label("Deprecated: Confidential request parameters (regex)") .description("Deprecated, use stagemonitor.requestmonitor.requestparams.confidential.regex instead." + "A list of request parameter name patterns that should not be collected.\n" + "A request parameter is either a query string or a application/x-www-form-urlencoded request " + "body (POST form content)") .tags("security-relevant", "deprecated") .configurationCategory(WEB_PLUGIN) .buildWithDefault(Arrays.asList( Pattern.compile("(?i).*pass.*"), Pattern.compile("(?i).*credit.*"), Pattern.compile("(?i).*pwd.*"))); private ConfigurationOption<Boolean> collectHttpHeaders = ConfigurationOption.booleanOption() .key("stagemonitor.requestmonitor.http.collectHeaders") .dynamic(true) .label("Collect HTTP headers") .description("Whether or not HTTP headers should be collected with a call stack.") .configurationCategory(WEB_PLUGIN) .tags("security-relevant") .buildWithDefault(true); private ConfigurationOption<Boolean> parseUserAgent = ConfigurationOption.booleanOption() .key("stagemonitor.requestmonitor.http.parseUserAgent") .dynamic(true) .label("Analyze user agent") .description("Whether or not the user-agent header should be parsed and analyzed to get information " + "about the browser, device type and operating system. If you want to enable this option, you have " + "to add a dependency on net.sf.uadetector:uadetector-resources:2014.10. As this library is no longer " + "maintained, it is however recommended to use the Elasticsearch ingest user agent plugin. See " + "https://www.elastic.co/guide/en/elasticsearch/plugins/master/ingest-user-agent.html") .tags("deprecated") .configurationCategory(WEB_PLUGIN) .buildWithDefault(false); private ConfigurationOption<Collection<String>> excludeHeaders = ConfigurationOption.lowerStringsOption() .key("stagemonitor.requestmonitor.http.headers.excluded") .dynamic(true) .label("Do not collect headers") .description("A list of (case insensitive) header names that should not be collected.") .configurationCategory(WEB_PLUGIN) .tags("security-relevant") .buildWithDefault(new LinkedHashSet<String>(Arrays.asList("cookie", "authorization", STAGEMONITOR_SHOW_WIDGET))); private final ConfigurationOption<Boolean> widgetEnabled = ConfigurationOption.booleanOption() .key("stagemonitor.web.widget.enabled") .dynamic(true) .label("In browser widget enabled") .description("If active, stagemonitor will inject a widget in the web site containing the call tree. " + "If disabled, you can still enable it for authorized users by sending the HTTP header " + "`X-Stagemonitor-Show-Widget: <stagemonitor.password>`. You can use browser plugins like Modify " + "Headers for this. Note: if `stagemonitor.password` is set to an empty string, you can't disable the widget.\n" + "Requires Servlet-Api >= 3.0") .configurationCategory(WEB_PLUGIN) .buildWithDefault(true); private final ConfigurationOption<Map<Pattern, String>> groupUrls = ConfigurationOption.regexMapOption() .key("stagemonitor.groupUrls") .dynamic(true) .label("Group URLs regex") .description("Combine url paths by regex to a single url group.\n" + "E.g. `(.*).js: *.js` combines all URLs that end with `.js` to a group named `*.js`. " + "The metrics for all URLs matching the pattern are consolidated and shown in one row in the request table. " + "The syntax is `<regex>: <group name>[, <regex>: <group name>]*`") .configurationCategory(WEB_PLUGIN) .buildWithDefault(new LinkedHashMap<Pattern, String>() {{ put(Pattern.compile("(.*).js$"), "*.js"); put(Pattern.compile("(.*).css$"), "*.css"); put(Pattern.compile("(.*).jpg$"), "*.jpg"); put(Pattern.compile("(.*).jpeg$"), "*.jpeg"); put(Pattern.compile("(.*).png$"), "*.png"); }}); private final ConfigurationOption<Boolean> rumEnabled = ConfigurationOption.booleanOption() .key("stagemonitor.web.rum.enabled") .dynamic(true) .label("Enable Real User Monitoring") .description("The Real User Monitoring feature collects the browser, network and overall percieved " + "execution time from the user's perspective. When activated, a piece of javascript will be " + "injected to each html page that collects the data from real users and sends it back " + "to the server. Servlet API 3.0 or higher is required for this.") .configurationCategory(WEB_PLUGIN) .buildWithDefault(true); private final ConfigurationOption<Boolean> collectPageLoadTimesPerRequest = ConfigurationOption.booleanOption() .key("stagemonitor.web.collectPageLoadTimesPerRequest") .dynamic(true) .label("Collect Page Load Time data per request group") .description("Whether or not browser, network and overall execution time should be collected per request group.\n" + "If set to true, four additional timers will be created for each request group to record the page " + "rendering time, dom processing time, network time and overall time per request. " + "If set to false, the times of all requests will be aggregated.") .configurationCategory(WEB_PLUGIN) .buildWithDefault(false); private final ConfigurationOption<Collection<String>> excludedRequestPaths = ConfigurationOption.stringsOption() .key("stagemonitor.web.paths.excluded") .dynamic(false) .label("Excluded paths") .description("Request paths that should not be monitored. " + "A value of `/aaa` means, that all paths starting with `/aaa` should not be monitored." + " It's recommended to not monitor static resources, as they are typically not interesting to " + "monitor but consume resources when you do.") .configurationCategory(WEB_PLUGIN) .buildWithDefault(SetValueConverter.immutableSet( // exclude paths of static vaadin resources "/VAADIN/", // don't monitor vaadin heatbeat "/HEARTBEAT/", "/favicon.ico")); private final ConfigurationOption<String> metricsServletAllowedOrigin = ConfigurationOption.stringOption() .key("stagemonitor.web.metricsServlet.allowedOrigin") .dynamic(true) .label("Allowed origin") .description("The Access-Control-Allow-Origin header value for the metrics servlet.") .configurationCategory(WEB_PLUGIN) .build(); private final ConfigurationOption<String> metricsServletJsonpParameter = ConfigurationOption.stringOption() .key("stagemonitor.web.metricsServlet.jsonpParameter") .dynamic(true) .label("The Jsonp callback parameter name") .description("The name of the parameter used to specify the jsonp callback.") .configurationCategory(WEB_PLUGIN) .build(); private ConfigurationOption<Boolean> monitorOnlySpringMvcOption = ConfigurationOption.booleanOption() .key("stagemonitor.requestmonitor.spring.monitorOnlySpringMvcRequests") .dynamic(true) .label("Monitor only SpringMVC requests") .description("Whether or not requests should be ignored, if they will not be handled by a Spring MVC controller method.\n" + "This is handy, if you are not interested in the performance of serving static files. " + "Setting this to true can also significantly reduce the amount of files (and thus storing space) " + "Graphite will allocate.") .configurationCategory("Spring MVC Plugin") .buildWithDefault(false); private ConfigurationOption<Boolean> monitorOnlyResteasyOption = ConfigurationOption.booleanOption() .key("stagemonitor.requestmonitor.resteasy.monitorOnlyResteasyRequests") .dynamic(true) .label("Monitor only Resteasy reqeusts") .description("Whether or not requests should be ignored, if they will not be handled by a Resteasy resource method.\n" + "This is handy, if you are not interested in the performance of serving static files. " + "Setting this to true can also significantly reduce the amount of files (and thus storing space) " + "Graphite will allocate.") .configurationCategory("Resteasy Plugin") .buildWithDefault(false); private ConfigurationOption<Collection<String>> requestExceptionAttributes = ConfigurationOption.stringsOption() .key("stagemonitor.requestmonitor.requestExceptionAttributes") .dynamic(true) .label("Request Exception Attributes") .description("Defines the list of attribute names to check on the HttpServletRequest when searching for an exception. \n\n" + "Stagemonitor searches this list in order to see if any of these attributes are set on the request with " + "an Exception object and then records that information on the span. If your web framework " + "sets a different attribute outside of the defaults, you can add that attribute to this list to properly " + "record the exception on the trace.") .configurationCategory(WEB_PLUGIN) .buildWithDefault(new LinkedHashSet<String>() {{ add("javax.servlet.error.exception"); add("exception"); add("org.springframework.web.servlet.DispatcherServlet.EXCEPTION"); }}); private ConfigurationOption<Boolean> honorDoNotTrackHeader = ConfigurationOption.booleanOption() .key("stagemonitor.web.honorDoNotTrackHeader") .dynamic(true) .label("Honor do not track header") .description("When set to true, requests that include the dnt header won't be reported. " + "Depending on your use case you might not be required to stop reporting spans even " + "if dnt is set. See https://tools.ietf.org/html/draft-mayer-do-not-track-00#section-9.3") .tags("privacy") .configurationCategory(WEB_PLUGIN) .buildWithDefault(false); @Override public void initializePlugin(StagemonitorPlugin.InitArguments initArguments) { registerPooledResources(initArguments.getMetricRegistry(), tomcatThreadPools()); final CorePlugin corePlugin = initArguments.getPlugin(CorePlugin.class); ElasticsearchClient elasticsearchClient = corePlugin.getElasticsearchClient(); if (corePlugin.isReportToGraphite()) { elasticsearchClient.sendGrafana1DashboardAsync("grafana/Grafana1GraphiteServer.json"); elasticsearchClient.sendGrafana1DashboardAsync("grafana/Grafana1GraphiteKPIsOverTime.json"); } if (corePlugin.isReportToElasticsearch()) { final GrafanaClient grafanaClient = corePlugin.getGrafanaClient(); elasticsearchClient.sendClassPathRessourceBulkAsync("kibana/Application-Server.bulk"); grafanaClient.sendGrafanaDashboardAsync("grafana/ElasticsearchApplicationServer.json"); } } @Override public List<ConfigurationOption<?>> getConfigurationOptions() { final List<ConfigurationOption<?>> configurationOptions = super.getConfigurationOptions(); if (!ClassUtils.isPresent("org.springframework.web.servlet.HandlerMapping")) { configurationOptions.remove(monitorOnlySpringMvcOption); } if (!ClassUtils.isPresent("org.jboss.resteasy.core.ResourceMethodRegistry")) { configurationOptions.remove(monitorOnlyResteasyOption); } return configurationOptions; } public boolean isCollectHttpHeaders() { return collectHttpHeaders.getValue(); } public boolean isParseUserAgent() { return parseUserAgent.getValue(); } public Collection<String> getExcludeHeaders() { return excludeHeaders.getValue(); } public boolean isWidgetEnabled() { return widgetEnabled.getValue(); } public Map<Pattern, String> getGroupUrls() { return groupUrls.getValue(); } public Collection<Pattern> getRequestParamsConfidential() { return requestParamsConfidential.getValue(); } public boolean isRealUserMonitoringEnabled() { return rumEnabled.getValue(); } public boolean isCollectPageLoadTimesPerRequest() { return collectPageLoadTimesPerRequest.getValue(); } public Collection<String> getExcludedRequestPaths() { return excludedRequestPaths.getValue(); } public String getMetricsServletAllowedOrigin() { return metricsServletAllowedOrigin.getValue(); } public String getMetricsServletJsonpParamName() { return metricsServletJsonpParameter.getValue(); } public boolean isWidgetAndStagemonitorEndpointsAllowed(HttpServletRequest request, ConfigurationRegistry configuration) { final Boolean showWidgetAttr = (Boolean) request.getAttribute(STAGEMONITOR_SHOW_WIDGET); if (showWidgetAttr != null) { logger.debug("isWidgetAndStagemonitorEndpointsAllowed: showWidgetAttr={}", showWidgetAttr); return showWidgetAttr; } final boolean widgetEnabled = isWidgetEnabled(); final boolean passwordInShowWidgetHeaderCorrect = isPasswordInShowWidgetHeaderCorrect(request, configuration); final boolean result = widgetEnabled || passwordInShowWidgetHeaderCorrect; logger.debug("isWidgetAndStagemonitorEndpointsAllowed: isWidgetEnabled={}, isPasswordInShowWidgetHeaderCorrect={}, result={}", widgetEnabled, passwordInShowWidgetHeaderCorrect, result); return result; } private boolean isPasswordInShowWidgetHeaderCorrect(HttpServletRequest request, ConfigurationRegistry configuration) { String password = request.getHeader(STAGEMONITOR_SHOW_WIDGET); if (configuration.isPasswordCorrect(password)) { return true; } else { if (StringUtils.isNotEmpty(password)) { logger.error("The password transmitted via the header {} is not correct. " + "This might be a malicious attempt to guess the value of {}. " + "The request was initiated from the ip {}.", STAGEMONITOR_SHOW_WIDGET, Stagemonitor.STAGEMONITOR_PASSWORD, MonitoredHttpRequest.getClientIp(request)); } return false; } } public boolean isMonitorOnlySpringMvcRequests() { return monitorOnlySpringMvcOption.getValue(); } public boolean isMonitorOnlyResteasyRequests() { return monitorOnlyResteasyOption.getValue(); } public Collection<String> getRequestExceptionAttributes() { return requestExceptionAttributes.getValue(); } public boolean isHonorDoNotTrackHeader() { return honorDoNotTrackHeader.getValue(); } @Override public void onStartup(Set<Class<?>> c, ServletContext ctx) { if (ServletContainerInitializerUtil.avoidDoubleInit(this, ctx)) return; ctx.addServlet(ConfigurationServlet.class.getSimpleName(), new ConfigurationServlet()) .addMapping(ConfigurationServlet.CONFIGURATION_ENDPOINT); ctx.addServlet(StagemonitorMetricsServlet.class.getSimpleName(), new StagemonitorMetricsServlet()) .addMapping("/stagemonitor/metrics"); ctx.addServlet(RumServlet.class.getSimpleName(), new RumServlet()) .addMapping("/stagemonitor/public/rum"); ctx.addServlet(StagemonitorFileServlet.class.getSimpleName(), new StagemonitorFileServlet()) .addMapping("/stagemonitor/static/*", "/stagemonitor/public/static/*"); ctx.addServlet(WidgetServlet.class.getSimpleName(), new WidgetServlet()) .addMapping("/stagemonitor"); final ServletRegistration.Dynamic spanServlet = ctx.addServlet(SpanServlet.class.getSimpleName(), new SpanServlet()); spanServlet.addMapping("/stagemonitor/spans"); spanServlet.setAsyncSupported(true); final FilterRegistration.Dynamic securityFilter = ctx.addFilter(StagemonitorSecurityFilter.class.getSimpleName(), new StagemonitorSecurityFilter()); // Add as last filter so that other filters have the chance to set the // WebPlugin.STAGEMONITOR_SHOW_WIDGET request attribute that overrides the widget visibility. // That way the application can decide whether a particular user is allowed to see the widget.P securityFilter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/stagemonitor/*"); securityFilter.setAsyncSupported(true); final FilterRegistration.Dynamic monitorFilter = ctx.addFilter(HttpRequestMonitorFilter.class.getSimpleName(), new HttpRequestMonitorFilter()); monitorFilter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*"); monitorFilter.setAsyncSupported(true); try { ctx.addListener(SessionCounter.class); } catch (IllegalArgumentException e) { // embedded servlet containers like jetty don't necessarily support sessions } } }