/* * Copyright © 2014-2015 Cask Data, Inc. * * 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 co.cask.cdap.metrics.query; import co.cask.cdap.api.dataset.lib.cube.AggregationFunction; import co.cask.cdap.api.dataset.lib.cube.Interpolator; import co.cask.cdap.api.dataset.lib.cube.Interpolators; import co.cask.cdap.api.metrics.MetricDataQuery; import co.cask.cdap.api.metrics.MetricDeleteQuery; import co.cask.cdap.common.conf.Constants; import co.cask.cdap.common.utils.TimeMathParser; import co.cask.cdap.proto.Id; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; import org.apache.commons.lang.CharEncoding; import org.jboss.netty.handler.codec.http.QueryStringDecoder; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; /** * For parsing metrics REST request. */ final class MetricQueryParser { private static final String COUNT = "count"; private static final String START_TIME = "start"; private static final String RESOLUTION = "resolution"; private static final String END_TIME = "end"; private static final String RUN_ID = "runs"; private static final String INTERPOLATE = "interpolate"; private static final String STEP_INTERPOLATOR = "step"; private static final String LINEAR_INTERPOLATOR = "linear"; private static final String MAX_INTERPOLATE_GAP = "maxInterpolateGap"; private static final String TRANSACTION_METRICS_CONTEXT = "transactions"; private static final String AUTO_RESOLUTION = "auto"; public enum PathType { APPS, DATASETS, STREAMS, CLUSTER, SERVICES, SPARK } // we need to duplicate that as we don't have dependency on app-fabric public enum ProgramType { FLOWS("f", Constants.Metrics.Tag.FLOW), MAPREDUCE("b", Constants.Metrics.Tag.MAPREDUCE), HANDLERS("h", Constants.Metrics.Tag.HANDLER), SERVICES("u", Constants.Metrics.Tag.SERVICE), SPARK("s", Constants.Metrics.Tag.SPARK); private final String code; private final String tagName; private ProgramType(String code, String tagName) { this.code = code; this.tagName = tagName; } public String getCode() { return code; } public String getTagName() { return tagName; } } private enum MapReduceType { MAPPERS("m"), REDUCERS("r"); private final String id; private MapReduceType(String id) { this.id = id; } private String getId() { return id; } } enum Resolution { SECOND(1), MINUTE(60), HOUR(3600); private int resolution; private Resolution(int resolution) { this.resolution = resolution; } public int getResolution() { return resolution; } } public enum MetricsScope { SYSTEM, USER } private static String urlDecode(String str) { try { return URLDecoder.decode(str, CharEncoding.UTF_8); } catch (UnsupportedEncodingException e) { throw new IllegalArgumentException("unsupported encoding in path element", e); } } /** * Given a full metrics path like '/v2/metrics/system/apps/collect.events', strip the preceding version and * metrics to return 'system/apps/collect.events', representing the context and metric, which can then be * parsed by this parser. * * @param path request path. * @return request path stripped of version and metrics. */ static String stripVersionAndMetricsFromPath(String path) { // +8 for "/metrics" int startPos = Constants.Gateway.API_VERSION_3.length() + 8; return path.substring(startPos, path.length()); } static MetricDeleteQuery parseDelete(URI requestURI, String metricPrefix) throws MetricsPathException { MetricDataQueryBuilder builder = new MetricDataQueryBuilder(); parseContext(requestURI.getPath(), builder); builder.setStartTs(0); builder.setEndTs(Integer.MAX_VALUE - 1); builder.setMetricName(metricPrefix); MetricDataQuery query = builder.build(); return new MetricDeleteQuery(query.getStartTs(), query.getEndTs(), query.getMetrics().keySet(), query.getSliceByTags()); } static MetricDataQuery parse(URI requestURI) throws MetricsPathException { MetricDataQueryBuilder builder = new MetricDataQueryBuilder(); // metric will be at the end. String uriPath = requestURI.getRawPath(); int index = uriPath.lastIndexOf("/"); builder.setMetricName(urlDecode(uriPath.substring(index + 1))); // strip the metric from the end of the path if (index != -1) { String strippedPath = uriPath.substring(0, index); if (strippedPath.startsWith("/system/cluster")) { builder.setSliceByTagValues(ImmutableMap.of(Constants.Metrics.Tag.NAMESPACE, Id.Namespace.SYSTEM.getId())); builder.setScope("system"); } else if (strippedPath.startsWith("/system/transactions")) { builder.setSliceByTagValues(ImmutableMap.of(Constants.Metrics.Tag.NAMESPACE, Id.Namespace.SYSTEM.getId(), Constants.Metrics.Tag.COMPONENT, TRANSACTION_METRICS_CONTEXT)); builder.setScope("system"); } else { parseContext(strippedPath, builder); } } else { builder.setSliceByTagValues(Maps.<String, String>newHashMap()); } parseQueryString(requestURI, builder); return builder.build(); } /** * Parse the context path, setting the relevant context fields in the builder. * Context starts after the scope and looks something like: * system/apps/{app-id}/{program-type}/{program-id}/{component-type}/{component-id} */ static void parseContext(String path, MetricDataQueryBuilder builder) throws MetricsPathException { Map<String, String> tagValues = Maps.newHashMap(); Iterator<String> pathParts = Splitter.on('/').omitEmptyStrings().split(path).iterator(); // everything if (!pathParts.hasNext()) { builder.setSliceByTagValues(tagValues); return; } // scope is the first part of the path String scopeStr = pathParts.next(); try { // we do conversion to validate value builder.setScope(MetricsScope.valueOf(scopeStr.toUpperCase()).toString().toLowerCase()); } catch (IllegalArgumentException e) { throw new MetricsPathException("invalid scope: " + scopeStr); } // streams, datasets, apps, or nothing. if (!pathParts.hasNext()) { builder.setSliceByTagValues(tagValues); return; } // apps, streams, or datasets String pathTypeStr = pathParts.next(); PathType pathType; try { pathType = PathType.valueOf(pathTypeStr.toUpperCase()); } catch (IllegalArgumentException e) { throw new MetricsPathException("invalid type: " + pathTypeStr); } switch (pathType) { case APPS: // Note: If v3 APIs use this class, we may have to get namespaceId from higher up tagValues.put(Constants.Metrics.Tag.NAMESPACE, Id.Namespace.DEFAULT.getId()); tagValues.put(Constants.Metrics.Tag.APP, urlDecode(pathParts.next())); parseSubContext(pathParts, tagValues); break; case STREAMS: // Note: If v3 APIs use this class, we may have to get namespaceId from higher up tagValues.put(Constants.Metrics.Tag.NAMESPACE, Id.Namespace.DEFAULT.getId()); if (!pathParts.hasNext()) { throw new MetricsPathException("'streams' must be followed by a stream name"); } tagValues.put(Constants.Metrics.Tag.STREAM, urlDecode(pathParts.next())); break; case DATASETS: // Note: If v3 APIs use this class, we may have to get namespaceId from higher up tagValues.put(Constants.Metrics.Tag.NAMESPACE, Id.Namespace.DEFAULT.getId()); if (!pathParts.hasNext()) { throw new MetricsPathException("'datasets' must be followed by a dataset name"); } tagValues.put(Constants.Metrics.Tag.DATASET, urlDecode(pathParts.next())); // path can be /metric/scope/datasets/{dataset}/apps/... if (pathParts.hasNext()) { if (!pathParts.next().equals("apps")) { throw new MetricsPathException("expecting 'apps' after stream or dataset name"); } tagValues.put(Constants.Metrics.Tag.APP, urlDecode(pathParts.next())); parseSubContext(pathParts, tagValues); } break; case SERVICES: // Note: If v3 APIs use this class, we may have to get namespaceId from higher up tagValues.put(Constants.Metrics.Tag.NAMESPACE, Id.Namespace.SYSTEM.getId()); parseSystemService(pathParts, tagValues); break; } if (pathParts.hasNext()) { throw new MetricsPathException("path contains too many elements: " + path); } builder.setSliceByTagValues(tagValues); } private static void parseSystemService(Iterator<String> pathParts, Map<String, String> tagValues) throws MetricsPathException { if (!pathParts.hasNext()) { throw new MetricsPathException("'services must be followed by a service name"); } tagValues.put(Constants.Metrics.Tag.COMPONENT, urlDecode(pathParts.next())); if (!pathParts.hasNext()) { return; } // skipping "/handlers" String next = pathParts.next(); if (!"handlers".equals(next)) { throw new MetricsPathException("'handlers must be followed by a service name"); } tagValues.put(Constants.Metrics.Tag.HANDLER, urlDecode(pathParts.next())); if (!pathParts.hasNext()) { return; } // skipping "/runs" next = pathParts.next(); if (RUN_ID.equals(next)) { tagValues.put(Constants.Metrics.Tag.RUN_ID, urlDecode(pathParts.next())); if (!pathParts.hasNext()) { return; } // skipping "/methods" pathParts.next(); } tagValues.put(Constants.Metrics.Tag.METHOD, urlDecode(pathParts.next())); } /** * pathParts should look like {app-id}/{program-type}/{program-id}/{component-type}/{component-id}. */ static void parseSubContext(Iterator<String> pathParts, Map<String, String> tagValues) throws MetricsPathException { if (!pathParts.hasNext()) { return; } // request-type: flows, mapreduce or handlers or services(user) String pathProgramTypeStr = pathParts.next(); ProgramType programType; try { programType = ProgramType.valueOf(pathProgramTypeStr.toUpperCase()); } catch (IllegalArgumentException e) { throw new MetricsPathException("invalid program type: " + pathProgramTypeStr); } if (pathParts.hasNext()) { tagValues.put(programType.getTagName(), pathParts.next()); } else { // given program type, match any type name under the type tagValues.put(programType.getTagName(), null); } if (!pathParts.hasNext()) { return; } switch (programType) { case MAPREDUCE: String mrTypeStr = pathParts.next(); if (mrTypeStr.equals(RUN_ID)) { parseRunId(pathParts, tagValues); if (pathParts.hasNext()) { mrTypeStr = pathParts.next(); } else { return; } } MapReduceType mrType; try { mrType = MapReduceType.valueOf(mrTypeStr.toUpperCase()); } catch (IllegalArgumentException e) { throw new MetricsPathException("invalid mapreduce component: " + mrTypeStr + ". must be 'mappers' or 'reducers'."); } tagValues.put(Constants.Metrics.Tag.MR_TASK_TYPE, mrType.getId()); break; case FLOWS: buildFlowletContext(pathParts, tagValues); break; case HANDLERS: buildComponentTypeContext(pathParts, tagValues, "methods", "handler", Constants.Metrics.Tag.METHOD); break; case SERVICES: buildComponentTypeContext(pathParts, tagValues, "handlers", "service", Constants.Metrics.Tag.HANDLER); break; case SPARK: if (pathParts.hasNext()) { if (pathParts.next().equals(RUN_ID)) { parseRunId(pathParts, tagValues); } } break; } if (pathParts.hasNext()) { throw new MetricsPathException("path contains too many elements"); } } private static void buildComponentTypeContext(Iterator<String> pathParts, Map<String, String> tagValues, String componentType, String requestType, String componentTagName) throws MetricsPathException { String nextPath = pathParts.next(); if (nextPath.equals(RUN_ID)) { tagValues.put(Constants.Metrics.Tag.RUN_ID, pathParts.next()); if (pathParts.hasNext()) { nextPath = pathParts.next(); } else { return; } } if (!nextPath.equals(componentType)) { String exception = String.format("Expecting '%s' after the %s name ", componentType, requestType.substring(0, requestType.length() - 1)); throw new MetricsPathException(exception); } if (!pathParts.hasNext()) { String exception = String.format("'%s' must be followed by a %s name ", componentType, componentType.substring(0, componentType.length() - 1)); throw new MetricsPathException(exception); } tagValues.put(componentTagName, urlDecode(pathParts.next())); } private static void parseRunId(Iterator<String> pathParts, Map<String, String> tagValues) throws MetricsPathException { if (!pathParts.hasNext()) { throw new MetricsPathException("expecting " + RUN_ID + " value after the identifier runs in path"); } tagValues.put(Constants.Metrics.Tag.RUN_ID, pathParts.next()); } /** * At this point, pathParts should look like flowlets/{flowlet-id}/queues/{queue-id}, with queues being optional. */ private static void buildFlowletContext(Iterator<String> pathParts, Map<String, String> tagValues) throws MetricsPathException { buildComponentTypeContext(pathParts, tagValues, "flowlets", "flows", Constants.Metrics.Tag.FLOWLET); if (pathParts.hasNext()) { if (!pathParts.next().equals("queues")) { throw new MetricsPathException("expecting 'queues' after the flowlet name"); } if (!pathParts.hasNext()) { throw new MetricsPathException("'queues' must be followed by a queue name"); } tagValues.put(Constants.Metrics.Tag.FLOWLET_QUEUE, urlDecode(pathParts.next())); } } /** * From the query string determine the query type, time range and related parameters. */ public static void parseQueryString(URI requestURI, MetricDataQueryBuilder builder) throws MetricsPathException { Map<String, List<String>> queryParams = new QueryStringDecoder(requestURI).getParameters(); parseTimeseries(queryParams, builder); } private static boolean isAutoResolution(Map<String, List<String>> queryParams) { return queryParams.get(RESOLUTION).get(0).equals(AUTO_RESOLUTION); } private static void parseTimeseries(Map<String, List<String>> queryParams, MetricDataQueryBuilder builder) { int count; long startTime; long endTime; int resolution; long now = TimeUnit.SECONDS.convert(System.currentTimeMillis(), TimeUnit.MILLISECONDS); if (queryParams.containsKey(RESOLUTION) && !isAutoResolution(queryParams)) { resolution = TimeMathParser.resolutionInSeconds(queryParams.get(RESOLUTION).get(0)); if (!((resolution == 3600) || (resolution == 60) || (resolution == 1))) { throw new IllegalArgumentException("Resolution interval not supported, only 1 second, 1 minute and " + "1 hour resolutions are supported currently"); } } else { // if resolution is not provided set default 1 resolution = 1; } if (queryParams.containsKey(START_TIME) && queryParams.containsKey(END_TIME)) { startTime = TimeMathParser.parseTimeInSeconds(now, queryParams.get(START_TIME).get(0)); endTime = TimeMathParser.parseTimeInSeconds(now, queryParams.get(END_TIME).get(0)); if (queryParams.containsKey(RESOLUTION)) { if (isAutoResolution(queryParams)) { // auto determine resolution, based on time difference. Resolution autoResolution = getResolution(endTime - startTime); resolution = autoResolution.getResolution(); } } else { resolution = Resolution.SECOND.getResolution(); } if (queryParams.containsKey(COUNT)) { count = Integer.parseInt(queryParams.get(COUNT).get(0)); } else { count = (int) (((endTime / resolution * resolution) - (startTime / resolution * resolution)) / resolution + 1); } } else if (queryParams.containsKey(COUNT)) { count = Integer.parseInt(queryParams.get(COUNT).get(0)); // both start and end times are inclusive, which is the reason for the +-1. if (queryParams.containsKey(START_TIME)) { startTime = TimeMathParser.parseTimeInSeconds(now, queryParams.get(START_TIME).get(0)); endTime = startTime + (count * resolution) - resolution; } else if (queryParams.containsKey(END_TIME)) { endTime = TimeMathParser.parseTimeInSeconds(now, queryParams.get(END_TIME).get(0)); startTime = endTime - (count * resolution) + resolution; } else { // if only count is specified, assume the current time is desired as the end. endTime = now - Constants.Metrics.Query.QUERY_SECOND_DELAY; startTime = endTime - (count * resolution) + resolution; } } else { startTime = 0; endTime = 0; count = 1; // max int means aggregate "all time totals" resolution = Integer.MAX_VALUE; } builder.setStartTs(startTime); builder.setEndTs(endTime); builder.setLimit(count); builder.setResolution(resolution); setInterpolator(queryParams, builder); } static Resolution getResolution(long difference) { if (difference > Constants.Metrics.Query.MAX_HOUR_RESOLUTION_QUERY_INTERVAL) { return Resolution.HOUR; } else if (difference > Constants.Metrics.Query.MAX_MINUTE_RESOLUTION_QUERY_INTERVAL) { return Resolution.MINUTE; } else { return Resolution.SECOND; } } private static void setInterpolator(Map<String, List<String>> queryParams, MetricDataQueryBuilder builder) { Interpolator interpolator = null; if (queryParams.containsKey(INTERPOLATE)) { String interpolatorType = queryParams.get(INTERPOLATE).get(0); // timeLimit used in case there is a big gap in the data and we don't want to interpolate points. // the limit defines how big the gap has to be in seconds before we just say they're all zeroes. long timeLimit = queryParams.containsKey(MAX_INTERPOLATE_GAP) ? Long.parseLong(queryParams.get(MAX_INTERPOLATE_GAP).get(0)) : Long.MAX_VALUE; if (STEP_INTERPOLATOR.equals(interpolatorType)) { interpolator = new Interpolators.Step(timeLimit); } else if (LINEAR_INTERPOLATOR.equals(interpolatorType)) { interpolator = new Interpolators.Linear(timeLimit); } } builder.setInterpolator(interpolator); } static class MetricDataQueryBuilder { private long startTs; private long endTs; private int resolution; private String scope; private String metricName; // todo: should be aggregation? e.g. also support min/max, etc. private Map<String, String> sliceByTagValues; private int limit; private Interpolator interpolator; public void setStartTs(long startTs) { this.startTs = startTs; } public void setEndTs(long endTs) { this.endTs = endTs; } public void setResolution(int resolution) { this.resolution = resolution; } public void setMetricName(String metricName) { this.metricName = metricName; } public void setScope(String scope) { this.scope = scope; } public void setSliceByTagValues(Map<String, String> sliceByTagValues) { this.sliceByTagValues = sliceByTagValues; } public void setLimit(int limit) { this.limit = limit; } public MetricDataQuery build() { Map<String, AggregationFunction> metrics = ImmutableMap.of(scope + "." + metricName, AggregationFunction.SUM); return new MetricDataQuery(startTs, endTs, resolution, limit, metrics, sliceByTagValues, new ArrayList<String>(), interpolator); } public void setInterpolator(Interpolator interpolator) { this.interpolator = interpolator; } } }