/* * Copyright 2013-2015 the original author or 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 org.springframework.cloud.netflix.metrics.atlas; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import com.fasterxml.jackson.core.JsonEncoding; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.dataformat.smile.SmileFactory; import com.netflix.servo.Metric; import com.netflix.servo.annotations.DataSourceType; import com.netflix.servo.monitor.MonitorConfig; import com.netflix.servo.publish.MetricObserver; import com.netflix.servo.tag.BasicTag; import com.netflix.servo.tag.Tag; import com.netflix.servo.tag.TagList; /** * Observer that forwards metrics to atlas. In addition to being a MetricObserver, it also * supports a push model that sends metrics as soon as possible (asynchronously). * * @author Jon Schneider */ public class AtlasMetricObserver implements MetricObserver { private static final Log logger = LogFactory.getLog(AtlasMetricObserver.class); private static final SmileFactory smileFactory = new SmileFactory(); private static final Tag atlasRateTag = new BasicTag("atlas.dstype", "rate"); private static final Tag atlasCounterTag = new BasicTag("atlas.dstype", "counter"); private static final Tag atlasGaugeTag = new BasicTag("atlas.dstype", "gauge"); private static final Pattern validAtlasTag = Pattern.compile("[\\.\\-\\w]+"); private AtlasMetricObserverConfigBean config; private RestTemplate restTemplate; private TagList commonTags; private String uri; public AtlasMetricObserver(AtlasMetricObserverConfigBean config, RestTemplate restTemplate, TagList commonTags) { this.config = config; this.commonTags = commonTags; this.restTemplate = restTemplate; this.uri = normalizeAtlasUri(config.getUri()); if (!validTags(commonTags)) { throw new IllegalArgumentException( "One or more atlas tags contain invalid characters, must match [\\.\\-\\w]+"); } } @Override public String getName() { return "atlas"; } protected static boolean validTags(TagList tags) { for (Tag tag : tags) { if (!validAtlasTag.matcher(tag.getKey()).matches()) { logger.debug("Invalid tag key " + tag.getKey()); return false; } if (!validAtlasTag.matcher(tag.getValue()).matches()) { logger.debug("Invalid tag value " + tag.getValue()); return false; } } return true; } static String normalizeAtlasUri(String uri) { if (uri != null) { Matcher matcher = Pattern.compile("(.+?)(/api/v1/publish)?/?").matcher(uri); if (matcher.matches()) return matcher.group(1) + "/api/v1/publish"; else throw new IllegalStateException("netflix.atlas.uri is not a valid uri"); } throw new IllegalStateException("netflix.atlas.uri was not found in your properties and is required to communicate with Atlas"); } @Override public void update(List<Metric> rawMetrics) { if (!config.isEnabled()) { logger.debug("Atlas metric observer disabled. Not sending metrics."); return; } if (rawMetrics.isEmpty()) { logger.debug("Metrics list is empty, no data being sent to server."); return; } List<Metric> metrics = sanitizeTags(addTypeTagsAsNecessary(rawMetrics)); for (int i = 0; i < metrics.size(); i += config.getBatchSize()) { List<Metric> batch = metrics.subList(i, Math.min(metrics.size(), config.getBatchSize() + i)); logger.debug("Sending a metrics batch of size " + batch.size()); sendMetricsBatch(batch); } } enum PublishMetricsBatchStatus { NothingToDo, Success, PartialSuccess, Failure } PublishMetricsBatchStatus sendMetricsBatch(List<Metric> metrics) { try { ByteArrayOutputStream output = new ByteArrayOutputStream(); JsonGenerator gen = smileFactory.createGenerator(output, JsonEncoding.UTF8); gen.writeStartObject(); writeCommonTags(gen); if (writeMetrics(gen, metrics) == 0) return PublishMetricsBatchStatus.NothingToDo; // short circuit this batch if no valid/numeric metrics existed gen.writeEndObject(); gen.flush(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.valueOf("application/x-jackson-smile")); HttpEntity<byte[]> entity = new HttpEntity<>(output.toByteArray(), headers); try { ResponseEntity<Map> response = restTemplate.exchange(uri, HttpMethod.POST, entity, Map.class); if(response.getStatusCode() == HttpStatus.ACCEPTED) { // partial success processing the metrics batch List<String> messages = (List<String>) response.getBody().get("message"); if(messages != null) { for (String message : messages) { logger.error("Failed to write metric to atlas: " + message); } } return PublishMetricsBatchStatus.PartialSuccess; } } catch (HttpClientErrorException e) { logger.error("Failed to write metrics to atlas: " + e.getResponseBodyAsString()); return PublishMetricsBatchStatus.Failure; } catch (RestClientException e) { logger.error("Failed to write metrics to atlas", e); return PublishMetricsBatchStatus.Failure; } } catch (IOException e) { return PublishMetricsBatchStatus.Failure; } return PublishMetricsBatchStatus.Success; } private void writeCommonTags(JsonGenerator gen) throws IOException { gen.writeObjectFieldStart("tags"); for (Tag tag : commonTags) gen.writeStringField(tag.getKey(), tag.getValue()); gen.writeEndObject(); } private int writeMetrics(JsonGenerator gen, List<Metric> metrics) throws IOException { int totalMetricsInBatch = 0; gen.writeArrayFieldStart("metrics"); for (Metric m : metrics) { if (!validTags(m.getConfig().getTags())) continue; if (!Number.class.isAssignableFrom(m.getValue().getClass())) continue; gen.writeStartObject(); gen.writeObjectFieldStart("tags"); gen.writeStringField("name", m.getConfig().getName()); for (Tag tag : m.getConfig().getTags()) gen.writeStringField(tag.getKey(), tag.getValue()); gen.writeEndObject(); gen.writeNumberField("start", m.getTimestamp()); gen.writeNumberField("value", m.getNumberValue().doubleValue()); gen.writeEndObject(); totalMetricsInBatch++; } gen.writeEndArray(); return totalMetricsInBatch; } static List<Metric> sanitizeTags(List<Metric> metrics) { List<Metric> sanitized = new ArrayList<>(metrics.size()); for (Metric m : metrics) { MonitorConfig.Builder config = MonitorConfig.builder(toValidCharset(m.getConfig().getName())); for (Tag tag : m.getConfig().getTags()) { config.withTag(toValidCharset(tag.getKey()), toValidCharset(tag.getValue())); } config.withPublishingPolicy(m.getConfig().getPublishingPolicy()); sanitized.add(new Metric(config.build(), m.getTimestamp(), m.getValue())); } return sanitized; } private static String toValidCharset(String name) { return name.replaceAll("[^\\.\\-\\w]", "_"); } static List<Metric> addTypeTagsAsNecessary(List<Metric> metrics) { List<Metric> typedMetrics = new ArrayList<>(metrics.size()); for (Metric m : metrics) { String value = m.getConfig().getTags().getValue(DataSourceType.KEY); Metric transformed; // Atlas will not normalize metrics tagged with atlas.dstype=gauge. Since // these metric types are pre-normalized, we do not want Atlas to touch the // value if (DataSourceType.GAUGE.name().equals(value) || DataSourceType.RATE.name().equals(value) || DataSourceType.NORMALIZED.name().equals(value)) { transformed = new Metric(m.getConfig().withAdditionalTag(atlasGaugeTag), m.getTimestamp(), m.getValue()); } // atlas.dstype=counter means you're sending the absolute value of the counter // (a monotonically increasing value), and Atlas will keep the previous value // and convert it to a rate per second when the metric is received else if (DataSourceType.COUNTER.name().equals(value)) { transformed = new Metric( m.getConfig().withAdditionalTag(atlasCounterTag), m.getTimestamp(), m.getValue()); } // Atlas will normalize the value to a minute boundary based on its timestamp else { transformed = new Metric(m.getConfig().withAdditionalTag(atlasRateTag), m.getTimestamp(), m.getValue()); } typedMetrics.add(transformed); } return typedMetrics; } }