diff --git a/src/Insights/Customizations/MetricOperations.Customization.cs b/src/Insights/Customizations/MetricOperations.Customization.cs index 1ae43a233dc8..dba9f0a36caf 100644 --- a/src/Insights/Customizations/MetricOperations.Customization.cs +++ b/src/Insights/Customizations/MetricOperations.Customization.cs @@ -21,125 +21,63 @@ using System.Threading; using System.Threading.Tasks; using Hyak.Common; +using Microsoft.Azure.Insights.Customizations.Shoebox; using Microsoft.Azure.Insights.Models; namespace Microsoft.Azure.Insights { + /// + /// Thick client class for getting metrics + /// internal partial class MetricOperations { public async Task GetMetricsAsync(string resourceUri, string filterString, CancellationToken cancellationToken) { - // Ensure exactly one '/' at the start - resourceUri = '/' + resourceUri.TrimStart('/'); + if (resourceUri == null) + { + throw new ArgumentNullException("resourceUri"); + } // Generate filter strings MetricFilter filter = MetricFilterExpressionParser.Parse(filterString); - string metricFilterString = GenerateNamelessMetricFilterString(filter); - string definitionFilterString = filter.DimensionFilters == null ? null + string filterStringNamesOnly = filter.DimensionFilters == null ? null : ShoeboxHelper.GenerateMetricDefinitionFilterString(filter.DimensionFilters.Select(df => df.Name)); // Get definitions for requested metrics IList definitions = (await this.Client.MetricDefinitionOperations.GetMetricDefinitionsAsync( resourceUri, - definitionFilterString, + filterStringNamesOnly, cancellationToken).ConfigureAwait(false)).MetricDefinitionCollection.Value; - // Separate passthrough metrics with dimensions specified - IEnumerable passthruDefinitions = definitions.Where(d => !IsShoebox(d, filter.TimeGrain)); - IEnumerable shoeboxDefinitions = definitions.Where(d => IsShoebox(d, filter.TimeGrain)); - - // Get Passthru definitions - List passthruMetrics = new List(); - string invocationId = TracingAdapter.NextInvocationId.ToString(CultureInfo.InvariantCulture); - this.LogStartGetMetrics(invocationId, resourceUri, filterString, passthruDefinitions); - if (passthruDefinitions.Any()) - { - // Create new filter for passthru metrics - List passthruDimensionFilters = filter.DimensionFilters == null ? new List() : - filter.DimensionFilters.Where(df => passthruDefinitions.Any(d => string.Equals(d.Name.Value, df.Name, StringComparison.OrdinalIgnoreCase))).ToList(); - - foreach (MetricDefinition def in passthruDefinitions - .Where(d => !passthruDimensionFilters.Any(pdf => string.Equals(pdf.Name, d.Name.Value, StringComparison.OrdinalIgnoreCase)))) - { - passthruDimensionFilters.Add(new MetricDimension() { Name = def.Name.Value }); - } - - MetricFilter passthruFilter = new MetricFilter() - { - TimeGrain = filter.TimeGrain, - StartTime = filter.StartTime, - EndTime = filter.EndTime, - DimensionFilters = passthruDimensionFilters - }; - - // Create passthru filter string - string passthruFilterString = ShoeboxHelper.GenerateMetricFilterString(passthruFilter); - - // Get Metrics from passthrough (hydra) client - MetricListResponse passthruResponse = await this.GetMetricsInternalAsync(resourceUri, passthruFilterString, cancellationToken).ConfigureAwait(false); - passthruMetrics = passthruResponse.MetricCollection.Value.ToList(); - - this.LogMetricCountFromResponses(invocationId, passthruMetrics.Count()); - - // Fill in values (resourceUri, displayName, unit) from definitions - CompleteShoeboxMetrics(passthruMetrics, passthruDefinitions, resourceUri); - - // Add empty objects for metrics that had no values come back, ensuring a metric is returned for each definition - IEnumerable emptyMetrics = passthruDefinitions - .Where(d => !passthruMetrics.Any(m => string.Equals(m.Name.Value, d.Name.Value, StringComparison.OrdinalIgnoreCase))) - .Select(d => new Metric() - { - Name = d.Name, - Unit = d.Unit, - ResourceId = resourceUri, - StartTime = filter.StartTime, - EndTime = filter.EndTime, - TimeGrain = filter.TimeGrain, - MetricValues = new List(), - Properties = new Dictionary() - }); - - passthruMetrics.AddRange(emptyMetrics); - } - - // Get Metrics by definitions - MetricListResponse shoeboxResponse = await this.GetMetricsAsync(resourceUri, metricFilterString, shoeboxDefinitions, cancellationToken).ConfigureAwait(false); - - // Create response (merge and wrap metrics) - MetricListResponse result = new MetricListResponse() - { - RequestId = Guid.NewGuid().ToString("D"), - StatusCode = HttpStatusCode.OK, - MetricCollection = new MetricCollection() - { - Value = passthruMetrics.Union(shoeboxResponse.MetricCollection.Value).ToList() - } - }; - - this.LogEndGetMetrics(invocationId, result); - - return result; + // Get Metrics with definitions + return await this.GetMetricsAsync(resourceUri, filterString, definitions, cancellationToken); } // Alternate method for getting metrics by passing in the definitions - // TODO [davmc]: Revisit - this method cannot support dimensions public async Task GetMetricsAsync( string resourceUri, string filterString, IEnumerable definitions, CancellationToken cancellationToken) { - MetricListResponse result; - if (definitions == null) { throw new ArgumentNullException("definitions"); } + if (resourceUri == null) + { + throw new ArgumentNullException("resourceUri"); + } + + // Ensure exactly one '/' at the start + resourceUri = '/' + resourceUri.TrimStart('/'); + + MetricListResponse result; string invocationId = TracingAdapter.NextInvocationId.ToString(CultureInfo.InvariantCulture); - this.LogStartGetMetrics(invocationId, resourceUri, filterString, definitions); // If no definitions provided, return empty collection if (!definitions.Any()) { + this.LogStartGetMetrics(invocationId, resourceUri, filterString, definitions); result = new MetricListResponse() { RequestId = Guid.NewGuid().ToString("D"), @@ -158,35 +96,73 @@ public async Task GetMetricsAsync( // Parse MetricFilter MetricFilter filter = MetricFilterExpressionParser.Parse(filterString); - // Names not allowed in filter since the names are in the definitions + // Names in filter must match the names in the definitions if (filter.DimensionFilters != null && filter.DimensionFilters.Any()) { - throw new ArgumentException("Cannot specify names (or dimensions) when MetricDefinitions are included", "filterString"); - } + IEnumerable filterNames = filter.DimensionFilters.Select(df => df.Name); + IEnumerable definitionNames = definitions.Select(d => d.Name.Value); + IEnumerable filterOnly = filterNames.Where(fn => !definitionNames.Contains(fn, StringComparer.InvariantCultureIgnoreCase)); + IEnumerable definitionOnly = definitionNames.Where(df => !filterNames.Contains(df, StringComparer.InvariantCultureIgnoreCase)); - // Ensure every definition has at least one availability matching the filter timegrain - if (!definitions.All(d => d.MetricAvailabilities.Any(a => a.TimeGrain == filter.TimeGrain))) + if (filterOnly.Any() || definitionOnly.Any()) + { + throw new ArgumentException("Set of names specified in filter string must match set of names in provided definitions", "filterString"); + } + + // "Filter out" metrics with unsupported dimensions + definitions = definitions.Where(d => SupportsRequestedDimensions(d, filter)); + } + else { - throw new ArgumentException("Definition contains no availability for the timeGrain requested", "definitions"); + filter = new MetricFilter() + { + TimeGrain = filter.TimeGrain, + StartTime = filter.StartTime, + EndTime = filter.EndTime, + DimensionFilters = definitions.Select(d => new MetricDimension() + { + Name = d.Name.Value + }) + }; } - // Group definitions by location so we can make one request to each location - Dictionary groups = - definitions.GroupBy(d => d.MetricAvailabilities.First(a => a.TimeGrain == filter.TimeGrain), - new AvailabilityComparer()).ToDictionary(g => g.Key, g => new MetricFilter() - { - TimeGrain = filter.TimeGrain, - StartTime = filter.StartTime, - EndTime = filter.EndTime, - DimensionFilters = g.Select(d => new MetricDimension() { Name = d.Name.Value }) - }); + // Parse out provider name and determine if provider is storage + string providerName = this.GetProviderFromResourceId(resourceUri); + bool isStorageProvider = + string.Equals(providerName, "Microsoft.Storage", StringComparison.OrdinalIgnoreCase) || + string.Equals(providerName, "Microsoft.ClassicStorage", StringComparison.OrdinalIgnoreCase); + + // Create supported MetricRetrievers + IMetricRetriever proxyRetriever = new ProxyMetricRetriever(this); + IMetricRetriever shoeboxRetriever = new ShoeboxMetricRetriever(); + IMetricRetriever storageRetriever = new StorageMetricRetriever(); + IMetricRetriever emptyRetriever = EmptyMetricRetriever.Instance; - // Get Metrics from each location (group) - IEnumerable> locationTasks = groups.Select(g => g.Key.Location == null - ? this.GetMetricsInternalAsync(resourceUri, ShoeboxHelper.GenerateMetricFilterString(g.Value), cancellationToken) - : ShoeboxClient.GetMetricsInternalAsync(g.Value, g.Key.Location, invocationId)); + // Create the selector function here so it has access to the retrievers, filter, and providerName + Func retrieverSelector = (d) => + { + if (!d.MetricAvailabilities.Any()) + { + return emptyRetriever; + } + + if (!IsSasMetric(d, filter.TimeGrain)) + { + return proxyRetriever; + } + + return isStorageProvider ? storageRetriever : shoeboxRetriever; + }; + + // Group definitions by retriever so we can make one request to each retriever + IEnumerable> groups = definitions.GroupBy(retrieverSelector); + + // Get Metrics from each retriever (group) + IEnumerable> locationTasks = groups.Select(g => + g.Key.GetMetricsAsync(resourceUri, GetFilterStringForDefinitions(filter, g), g, invocationId)); // Aggregate metrics from all groups + this.LogStartGetMetrics(invocationId, resourceUri, filterString, definitions); MetricListResponse[] results = (await Task.Factory.ContinueWhenAll(locationTasks.ToArray(), tasks => tasks.Select(t => t.Result))).ToArray(); IEnumerable metrics = results.Aggregate>( new List(), (list, response) => list.Union(response.MetricCollection.Value)); @@ -197,19 +173,11 @@ public async Task GetMetricsAsync( CompleteShoeboxMetrics(metrics, definitions, resourceUri); // Add empty objects for metrics that had no values come back, ensuring a metric is returned for each definition - IEnumerable emptyMetrics = definitions - .Where(d => !metrics.Any(m => string.Equals(m.Name.Value, d.Name.Value, StringComparison.OrdinalIgnoreCase))) - .Select(d => new Metric() - { - Name = d.Name, - Unit = d.Unit, - ResourceId = resourceUri, - StartTime = filter.StartTime, - EndTime = filter.EndTime, - TimeGrain = filter.TimeGrain, - MetricValues = new List(), - Properties = new Dictionary() - }); + IEnumerable emptyMetrics = (await emptyRetriever.GetMetricsAsync( + resourceUri, + filterString, + definitions.Where(d => !metrics.Any(m => string.Equals(m.Name.Value, d.Name.Value, StringComparison.OrdinalIgnoreCase))), + invocationId)).MetricCollection.Value; // Create response (merge and wrap metrics) result = new MetricListResponse() @@ -227,6 +195,65 @@ public async Task GetMetricsAsync( return result; } + private string GetProviderFromResourceId(string resourceId) + { + // Find start index of provider name + string knownStart = "/subscriptions/" + this.Client.Credentials.SubscriptionId + "/resourceGroups/"; + int endOfResourceGroup = resourceId.IndexOf('/', knownStart.Length); + + // skip /providers/ + // plus 1 to start index to skip first '/', plus 1 to result to skip last '/' + int startOfProviderName = resourceId.IndexOf('/', endOfResourceGroup + 1) + 1; + int endOfProviderName = resourceId.IndexOf('/', startOfProviderName); + + return resourceId.Substring(startOfProviderName, endOfProviderName - startOfProviderName); + } + + private string GetFilterStringForDefinitions(MetricFilter filter, IEnumerable definitions) + { + return ShoeboxHelper.GenerateMetricFilterString(new MetricFilter() + { + TimeGrain = filter.TimeGrain, + StartTime = filter.StartTime, + EndTime = filter.EndTime, + DimensionFilters = filter.DimensionFilters.Where(df => + definitions.Any(d => string.Equals(df.Name, d.Name.Value, StringComparison.OrdinalIgnoreCase))) + }); + } + + private bool SupportsRequestedDimensions(MetricDefinition definition, MetricFilter filter) + { + MetricDimension metric = filter.DimensionFilters.FirstOrDefault(df => string.Equals(df.Name, definition.Name.Value, StringComparison.OrdinalIgnoreCase)); + var supportedDimensionNames = definition.Dimensions.Select(dim => dim.Name); + var supportedDimensionValues = definition.Dimensions.ToDictionary(dim => dim.Name.Value, dim => dim.Values.Select(v => v.Value)); + + // No dimensions specified for this metric + if (metric == null || metric.Dimensions == null) + { + return true; + } + + foreach (FilterDimension dimension in metric.Dimensions) + { + // find dimension in definition + Dimension d = definition.Dimensions.FirstOrDefault(dim => string.Equals(dim.Name.Value, dimension.Name)); + + // Dimension name does't show up in definition + if (d == null) + { + return false; + } + + // Requested dimension has any value that don't show up in the values list for the definiton + if (dimension.Values.Any(value => !d.Values.Select(v => v.Value).Contains(value, StringComparer.OrdinalIgnoreCase))) + { + return false; + } + } + + return true; + } + private void LogMetricCountFromResponses(string invocationId, int metricsCount) { if (TracingAdapter.IsEnabled) @@ -270,52 +297,27 @@ private static void CompleteShoeboxMetrics(IEnumerable collection, IEnum } } - private static bool IsShoebox(MetricDefinition definition, TimeSpan timeGrain) + private static bool IsSasMetric(MetricDefinition definition, TimeSpan timeGrain) { MetricAvailability availability = definition.MetricAvailabilities.FirstOrDefault(a => a.TimeGrain.Equals(timeGrain)); - if (availability == null) + // Definition has requested availability, Location is null (non-SAS) or contains SAS key + if (availability != null) { - throw new InvalidOperationException(string.Format( - CultureInfo.InvariantCulture, "MetricDefinition for {0} does not contain an availability with timegrain {1}", definition.Name.Value, timeGrain)); + return availability.Location != null; } - return availability.Location != null; - } - - private static string GenerateNamelessMetricFilterString(MetricFilter filter) - { - MetricFilter nameless = new MetricFilter() + // Definition has availabilities, but none with the requested timegrain (Bad request) + if (definition.MetricAvailabilities.Any()) { - TimeGrain = filter.TimeGrain, - StartTime = filter.StartTime, - EndTime = filter.EndTime - }; - - return ShoeboxHelper.GenerateMetricFilterString(nameless); - } - - private class AvailabilityComparer : IEqualityComparer - { - public bool Equals(MetricAvailability x, MetricAvailability y) - { - if (x.Location == null && y.Location == null) - { - return true; - } - - if (x.Location == null || y.Location == null) - { - return false; - } - - return x.Location.TableEndpoint == y.Location.TableEndpoint; - } - - public int GetHashCode(MetricAvailability obj) - { - return obj.Location == null ? 0 : obj.Location.TableEndpoint.GetHashCode(); + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "MetricDefinition for {0} does not contain an availability with timegrain {1}", + definition.Name.Value, timeGrain)); } + + // Definition has no availablilities (metrics are not configured for this resource), return empty metrics (non-SAS) + return false; } } } diff --git a/src/Insights/Customizations/Shoebox/EmptyMetricRetriever.cs b/src/Insights/Customizations/Shoebox/EmptyMetricRetriever.cs new file mode 100644 index 000000000000..cd4d75d0034c --- /dev/null +++ b/src/Insights/Customizations/Shoebox/EmptyMetricRetriever.cs @@ -0,0 +1,55 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// + +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Microsoft.Azure.Insights.Models; + +namespace Microsoft.Azure.Insights.Customizations.Shoebox +{ + /// + /// EmptyMetricRetriever always returns a metric for every definition passed in. These metrics will have an empty list of MetricValues + /// EmptyMetricRetriever ignores dimensions + /// + internal class EmptyMetricRetriever : IMetricRetriever + { + private static readonly EmptyMetricRetriever _instance = new EmptyMetricRetriever(); + + private EmptyMetricRetriever() + { + } + + public static EmptyMetricRetriever Instance + { + get { return _instance; } + } + + public Task GetMetricsAsync(string resourceId, string filterString, IEnumerable definitions, string invocationId) + { + MetricFilter filter = MetricFilterExpressionParser.Parse(filterString); + + return Task.Factory.StartNew(() => new MetricListResponse() + { + RequestId = invocationId, + StatusCode = HttpStatusCode.OK, + MetricCollection = new MetricCollection() + { + Value = definitions == null ? new List() : definitions.Select(d => new Metric() + { + Name = d.Name, + Unit = d.Unit, + ResourceId = resourceId, + StartTime = filter.StartTime, + EndTime = filter.EndTime, + TimeGrain = filter.TimeGrain, + MetricValues = new List(), + Properties = new Dictionary() + }).ToList() + } + }); + } + } +} diff --git a/src/Insights/Customizations/Shoebox/IMetricRetriever.cs b/src/Insights/Customizations/Shoebox/IMetricRetriever.cs new file mode 100644 index 000000000000..b79dc0dd98d3 --- /dev/null +++ b/src/Insights/Customizations/Shoebox/IMetricRetriever.cs @@ -0,0 +1,18 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Azure.Insights.Models; + +namespace Microsoft.Azure.Insights.Customizations.Shoebox +{ + /// + /// Generic interface for retrieving various types of metrics + /// + internal interface IMetricRetriever + { + Task GetMetricsAsync(string resourceId, string filterString, IEnumerable definitions, string invocationId); + } +} diff --git a/src/Insights/Customizations/Shoebox/ProxyMetricRetriever.cs b/src/Insights/Customizations/Shoebox/ProxyMetricRetriever.cs new file mode 100644 index 000000000000..0f14104d916b --- /dev/null +++ b/src/Insights/Customizations/Shoebox/ProxyMetricRetriever.cs @@ -0,0 +1,30 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.Insights.Models; + +namespace Microsoft.Azure.Insights.Customizations.Shoebox +{ + /// + /// Metric retriever for delivering proxy metrics by calling RP via REST + /// ProxyMetricRetriever supports dimensions (if the RP supports them) + /// + internal class ProxyMetricRetriever : IMetricRetriever + { + private readonly MetricOperations metricOperations; + + public ProxyMetricRetriever(MetricOperations operations) + { + this.metricOperations = operations; + } + + public Task GetMetricsAsync(string resourceId, string filterString, IEnumerable definitions, string invocationId) + { + return this.metricOperations.GetMetricsInternalAsync(resourceId, filterString, CancellationToken.None); + } + } +} diff --git a/src/Insights/Customizations/Shoebox/SasMetricRetriever.cs b/src/Insights/Customizations/Shoebox/SasMetricRetriever.cs new file mode 100644 index 000000000000..1f3b3ad51123 --- /dev/null +++ b/src/Insights/Customizations/Shoebox/SasMetricRetriever.cs @@ -0,0 +1,235 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Hyak.Common; +using Microsoft.Azure.Insights.Models; +using Microsoft.WindowsAzure.Storage.Table; + +namespace Microsoft.Azure.Insights.Customizations.Shoebox +{ + /// + /// Base metric retriever for SAS-based metrics + /// + internal abstract class SasMetricRetriever : IMetricRetriever + { + public async Task GetMetricsAsync(string resourceId, string filterString, IEnumerable definitions, string invocationId) + { + MetricFilter filter = MetricFilterExpressionParser.Parse(filterString); + + // Group definitions by location so we can make one request to each location + Dictionary groups = + definitions.GroupBy(d => d.MetricAvailabilities.First(a => a.TimeGrain == filter.TimeGrain), + new SasMetricRetriever.AvailabilityComparer()).ToDictionary(g => g.Key, g => new MetricFilter() + { + TimeGrain = filter.TimeGrain, + StartTime = filter.StartTime, + EndTime = filter.EndTime, + DimensionFilters = g.Select(d => + filter.DimensionFilters.FirstOrDefault(df => string.Equals(df.Name, d.Name.Value, StringComparison.OrdinalIgnoreCase)) + ?? new MetricDimension() {Name = d.Name.Value}) + }); + + // Verify all groups represent shoebox metrics + if (groups.Any(g => g.Key.Location == null)) + { + throw new ArgumentException("All definitions provided to ShoeboxMetricRetriever must include location information.", "definitions"); + } + + // Get Metrics from each location (group) + IEnumerable> locationTasks = groups.Select(g => this.GetMetricsInternalAsync(g.Value, g.Key.Location, invocationId)); + + // Aggregate metrics from all groups + MetricListResponse[] results = (await Task.Factory.ContinueWhenAll(locationTasks.ToArray(), tasks => tasks.Select(t => t.Result))).ToArray(); + IEnumerable metrics = results.Aggregate>( + new List(), (list, response) => list.Union(response.MetricCollection.Value)); + + // Return aggregated results (the MetricOperations class will fill in additional info from the MetricDefinitions) + return new MetricListResponse() + { + RequestId = invocationId, + StatusCode = HttpStatusCode.OK, + MetricCollection = new MetricCollection() + { + Value = metrics.ToList() + } + }; + } + + /// + /// Retrieves the metric values from the shoebox + /// + /// The $filter query string + /// The MetricLocation object + /// The invocation id + /// The MetricValueListResponse + // Note: Does not populate Metric fields unrelated to query (i.e. "display name", resourceUri, and properties) + internal MetricListResponse GetMetricsInternal(MetricFilter filter, MetricLocation location, string invocationId) + { + return GetMetricsInternalAsync(filter, location, invocationId).Result; + } + + /// + /// Retrieves the metric values from the shoebox + /// + /// The $filter query string + /// The MetricLocation object + /// The invocation id + /// The MetricValueListResponse + // Note: Does not populate Metric fields unrelated to query (i.e. "display name", resourceUri, and properties) + internal abstract Task GetMetricsInternalAsync(MetricFilter filter, MetricLocation location, string invocationId); + + protected static async Task> GetEntitiesAsync(CloudTable table, TableQuery query, string invocationId, int maxBatchSize = 0) where TEntity : ITableEntity, new() + { + string traceRequestId; + List results = new List(); + + try + { + // If 0 or less then there is no max value + maxBatchSize = maxBatchSize <= 0 ? int.MaxValue : maxBatchSize; + TableContinuationToken continuationToken = null; + do + { + continuationToken = null; + TableQuerySegment resultSegment = null; + + traceRequestId = Guid.NewGuid().ToString(); + + TableOperationContextLogger operationContextLogger = new TableOperationContextLogger( + accountName: table.ServiceClient.Credentials.AccountName, + resourceUri: table.Name, + operationName: "ExecuteQuerySegmentedAsync", + requestId: invocationId); + + resultSegment = await table.ExecuteQuerySegmentedAsync(query, continuationToken, requestOptions: new TableRequestOptions(), operationContext: operationContextLogger.OperationContext); + + if (resultSegment != null) + { + var count = resultSegment.Results == null ? 0 : resultSegment.Results.Count; + var recordsToInclude = Math.Min(maxBatchSize - results.Count, count); + + if (resultSegment.Results != null) + { + results.AddRange(resultSegment.Results.Take(recordsToInclude)); + } + + continuationToken = resultSegment.ContinuationToken; + } + } + while (continuationToken != null && results.Count < maxBatchSize); + } + catch (Exception ex) + { + TracingAdapter.Error(invocationId, ex); + throw ex.GetBaseException(); + } + + return results; + } + + protected static MetricValue GetMetricValueFromEntity(DynamicTableEntity entity, string metricName) + { + // Get the key (metric name) from the entity properties dictionary to retrieve the correctly-cased key since the key lookup is case sensitive + string key = entity.Properties.Keys.FirstOrDefault(k => string.Equals(k, metricName, StringComparison.OrdinalIgnoreCase)); + + // metric name does not exist in keys + if (key == null) + { + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Unable to retrieve metric {0}. Metric name does not exist", metricName), "metricName"); + } + + MetricValue value = new MetricValue(); + DateTime created; + + if (!DateTime.TryParseExact(entity.PartitionKey, "yyyyMMddTHHmm", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out created)) + { + // trace failure as best possible + TracingAdapter.Information("Failed to parse partition key (date) {0}", entity.PartitionKey); + } + + // dateTime is not parsing and correctly setting the kind so we force it here + value.Timestamp = DateTime.SpecifyKind(created, DateTimeKind.Utc); + value.Count = 1; + + // convert value to double + switch (entity.Properties[key].PropertyType) + { + case EdmType.Double: + SetAllMetricValues(value, entity.Properties[key].DoubleValue ?? 0); + break; + case EdmType.Int32: + SetAllMetricValues(value, entity.Properties[key].Int32Value ?? 0); + break; + case EdmType.Int64: + SetAllMetricValues(value, entity.Properties[key].Int64Value ?? 0); + break; + default: + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Table value for column {0} is not a numeric type", metricName)); + } + + return value; + } + + private static void SetAllMetricValues(MetricValue metric, double value) + { + metric.Average = value; + metric.Last = value; + metric.Maximum = value; + metric.Minimum = value; + metric.Total = value; + } + + protected static IEnumerable CollectResults(IEnumerable>> tasks) + { + return tasks.Aggregate((IEnumerable)new T[0], (list, t) => list.Union(t.Result)); + } + + protected static async Task> CollectResultsAsync(IEnumerable>> tasks) + { + return await Task.Factory.ContinueWhenAll(tasks.ToArray(), (t) => CollectResults(t)).ConfigureAwait(false); + } + + // Creates a TableQuery object which filters entities to a particular partition key and the given row key range + protected static TableQuery GenerateMetricQuery(string partitionKey, string startRowKey, string endRowKey) + { + string partitionFilter = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, partitionKey); + string rowStartFilter = TableQuery.GenerateFilterCondition("RowKey", QueryComparisons.GreaterThanOrEqual, startRowKey); + string rowEndFilter = TableQuery.GenerateFilterCondition("RowKey", QueryComparisons.LessThan, endRowKey); + + return new TableQuery() + { + FilterString = TableQuery.CombineFilters(partitionFilter, TableOperators.And, TableQuery.CombineFilters(rowStartFilter, TableOperators.And, rowEndFilter)) + }; + } + + private class AvailabilityComparer : IEqualityComparer + { + public bool Equals(MetricAvailability x, MetricAvailability y) + { + if (x.Location == null && y.Location == null) + { + return true; + } + + if (x.Location == null || y.Location == null) + { + return false; + } + + return x.Location.TableEndpoint == y.Location.TableEndpoint; + } + + public int GetHashCode(MetricAvailability obj) + { + return obj.Location == null ? 0 : obj.Location.TableEndpoint.GetHashCode(); + } + } + } +} diff --git a/src/Insights/Customizations/Shoebox/ShoeboxClient.cs b/src/Insights/Customizations/Shoebox/ShoeboxClient.cs index 1b6b1a525d8e..ef8274925cbb 100644 --- a/src/Insights/Customizations/Shoebox/ShoeboxClient.cs +++ b/src/Insights/Customizations/Shoebox/ShoeboxClient.cs @@ -17,12 +17,16 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Hyak.Common; using Microsoft.Azure.Insights.Models; using Microsoft.WindowsAzure.Storage.Auth; using Microsoft.WindowsAzure.Storage.Table; namespace Microsoft.Azure.Insights { + /// + /// Thick client component for retrieving shoebox metrics + /// internal static class ShoeboxClient { internal static int MaxParallelRequestsByName = 4; @@ -53,7 +57,22 @@ internal static async Task GetMetricsInternalAsync(MetricFil // TODO [davmc]: ShoeboxClient doesn't support dimensions if (filter.DimensionFilters != null && filter.DimensionFilters.Any(df => df.Dimensions != null)) { - throw new ArgumentException("Shoebox client does not support dimensions", "filter"); + if (TracingAdapter.IsEnabled) + { + TracingAdapter.Information("InvocationId: {0}. ShoeboxClient encountered metrics with dimensions specified. These will be ignored.", invocationId); + } + + // Remove dimensions from filter (The MetricFilter class has strict mutation rules used in parsing so the best way to modify it is to create a new one) + filter = new MetricFilter() + { + TimeGrain = filter.TimeGrain, + StartTime = filter.StartTime, + EndTime = filter.EndTime, + DimensionFilters = filter.DimensionFilters.Select(df => new MetricDimension() + { + Name = df.Name + }) + }; } // If metrics are requested by name, get those metrics specifically, unless too many are requested. diff --git a/src/Insights/Customizations/Shoebox/ShoeboxHelper.cs b/src/Insights/Customizations/Shoebox/ShoeboxHelper.cs index dd6301b4538d..178f264b15e5 100644 --- a/src/Insights/Customizations/Shoebox/ShoeboxHelper.cs +++ b/src/Insights/Customizations/Shoebox/ShoeboxHelper.cs @@ -22,6 +22,9 @@ namespace Microsoft.Azure.Insights { + /// + /// Helper class for shoebox operations + /// internal static class ShoeboxHelper { private const int KeyLimit = 432; diff --git a/src/Insights/Customizations/Shoebox/ShoeboxMetricRetriever.cs b/src/Insights/Customizations/Shoebox/ShoeboxMetricRetriever.cs new file mode 100644 index 000000000000..e2d38f4fd281 --- /dev/null +++ b/src/Insights/Customizations/Shoebox/ShoeboxMetricRetriever.cs @@ -0,0 +1,22 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// + +using System.Threading.Tasks; +using Microsoft.Azure.Insights.Models; + +namespace Microsoft.Azure.Insights.Customizations.Shoebox +{ + /// + /// Metric retriever for getting metrics in "shoebox" storage accounts using provided SAS keys + /// ShoeboxMetricRetriever ignores dimensions + /// + /// TODO: Refactor shoebox client to inherit table operations and diminsions support from SasMetricRetriever + internal class ShoeboxMetricRetriever : SasMetricRetriever + { + internal override async Task GetMetricsInternalAsync(MetricFilter filter, MetricLocation location, string invocationId) + { + return await ShoeboxClient.GetMetricsInternalAsync(filter, location, invocationId); + } + } +} diff --git a/src/Insights/Customizations/Shoebox/StorageConstants.cs b/src/Insights/Customizations/Shoebox/StorageConstants.cs new file mode 100644 index 000000000000..82a4574275f1 --- /dev/null +++ b/src/Insights/Customizations/Shoebox/StorageConstants.cs @@ -0,0 +1,75 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Azure.Insights.Customizations.Shoebox +{ + /// + /// Constants used for retrieving Storage metrics + /// + // TODO: Update StorageMetricRetriever to determine dimension name and capacity metrics from definitons + internal static class StorageConstants + { + // Timegrain constants + internal static readonly TimeSpan PT1M = TimeSpan.FromMinutes(1); + internal static readonly TimeSpan PT1H = TimeSpan.FromHours(1); + internal static readonly TimeSpan P1D = TimeSpan.FromDays(1); + + internal static readonly string CapacityMetricsTableName = "$MetricsCapacityBlob"; + + // supported dimensions + internal static class Dimensions + { + // Data type dimension (not supported) + public static readonly string TransactionDataTypeDimensionUserValue = "user"; + + // Operation name dimension + public static readonly string ApiDimensionName = "apiName"; + public static readonly string ApiDimensionAggregateValue = "All"; + } + + internal static class MetricNames + { + public static readonly string Capacity = "Capacity"; + public static readonly string ContainerCount = "ContainerCount"; + public static readonly string ObjectCount = "ObjectCount"; + + public static readonly HashSet CapacityMetrics = new HashSet(new[] { Capacity, ContainerCount, ObjectCount }); + + public static bool IsCapacityMetric(string metricName) + { + return CapacityMetrics.Contains(metricName, StringComparer.OrdinalIgnoreCase); + } + } + + // Table name funtions + internal static bool IsCapacityMetricsTable(string tableName) + { + return CapacityMetricsTableName.Equals(tableName); + } + + internal static bool IsTransactionMetricsTable(string tableName) + { + return tableName.IndexOf("Transaction", StringComparison.OrdinalIgnoreCase) >= 0; + } + + internal static bool IsBlobMetricsTable(string tableName) + { + return tableName.IndexOf("Blob", StringComparison.OrdinalIgnoreCase) >= 0; + } + + internal static bool IsTableMetricsTable(string tableName) + { + return tableName.IndexOf("Table", StringComparison.OrdinalIgnoreCase) >= 0; + } + + internal static bool IsQueueMetricsTable(string tableName) + { + return tableName.IndexOf("Queue", StringComparison.OrdinalIgnoreCase) >= 0; + } + } +} diff --git a/src/Insights/Customizations/Shoebox/StorageMetricRetriever.cs b/src/Insights/Customizations/Shoebox/StorageMetricRetriever.cs new file mode 100644 index 000000000000..83ef17e3125a --- /dev/null +++ b/src/Insights/Customizations/Shoebox/StorageMetricRetriever.cs @@ -0,0 +1,306 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Microsoft.Azure.Insights.Models; +using Microsoft.WindowsAzure.Storage.Auth; +using Microsoft.WindowsAzure.Storage.Table; + +namespace Microsoft.Azure.Insights.Customizations.Shoebox +{ + /// + /// Metric retriever for getting Storage metrics directly from Storage + /// + internal class StorageMetricRetriever : SasMetricRetriever + { + // Calling this based on a grouping (in SasMetricRetriever) should guarantee that it will have metric names specified (cannot have empty group) + internal override async Task GetMetricsInternalAsync(MetricFilter filter, MetricLocation location, string invocationId) + { + if (filter == null) + { + throw new ArgumentNullException("filter"); + } + + if (location == null) + { + throw new ArgumentNullException("location"); + } + + // This is called based on the definitions no the dimension portion of the filter should never be null or empty + if (filter.DimensionFilters == null || !filter.DimensionFilters.Any()) + { + throw new ArgumentNullException("filter.DimensionFilters"); + } + + // Separate out capacity metrics and transaction metrics into two groups + IEnumerable capacityMetrics = filter.DimensionFilters.Select(df => df.Name).Where(StorageConstants.MetricNames.IsCapacityMetric); + IEnumerable transactionMetrics = filter.DimensionFilters.Select(df => df.Name).Where(n => !StorageConstants.MetricNames.IsCapacityMetric(n)); + + List>> queryTasks = new List>>(); + + // Add task to get capacity metrics (if any) + if (capacityMetrics.Any()) + { + MetricTableInfo capacityTableInfo = location.TableInfo.FirstOrDefault(ti => StorageConstants.IsCapacityMetricsTable(ti.TableName)); + if (capacityTableInfo == null) + { + throw new InvalidOperationException("Definitions for capacity metrics must contain table info for capacity metrics table"); + } + + queryTasks.Add(GetCapacityMetricsAsync(filter, GetTableReference(location, capacityTableInfo), capacityMetrics, invocationId)); + } + + // Add tasks to get transaction metrics (if any) + if (transactionMetrics.Any()) + { + IEnumerable transactionTableInfos = location.TableInfo.Where(ti => !StorageConstants.IsCapacityMetricsTable(ti.TableName)); + if (!transactionTableInfos.Any()) + { + throw new InvalidOperationException("Definitions for transaction metrics must contain table info for transaction metrics table"); + } + + queryTasks.AddRange(transactionTableInfos + .Select(info => GetTransactionMetricsAsync(filter, GetTableReference(location, info), transactionMetrics, invocationId))); + } + + // Collect results and wrap + return new MetricListResponse() + { + RequestId = invocationId, + StatusCode = HttpStatusCode.OK, + MetricCollection = new MetricCollection() + { + Value = (await CollectResultsAsync(queryTasks)).ToList() + } + }; + } + + private static CloudTable GetTableReference(MetricLocation location, MetricTableInfo tableInfo) + { + return new CloudTableClient(new Uri(location.TableEndpoint), new StorageCredentials(tableInfo.SasToken)).GetTableReference(tableInfo.TableName); + } + + private static async Task> GetCapacityMetricsAsync(MetricFilter filter, CloudTable table, IEnumerable metricNames, string invocationId) + { + IEnumerable entities = await SasMetricRetriever.GetEntitiesAsync(table, GetCapacityQuery(filter), invocationId); + + return metricNames.Select(n => new Metric() + { + Name = new LocalizableString() + { + Value = n, + LocalizedValue = n + }, + StartTime = filter.StartTime, + EndTime = filter.EndTime, + TimeGrain = filter.TimeGrain, + Properties = new Dictionary(), + MetricValues = entities.Select(entity => GetMetricValueFromEntity(entity, n)).ToList() + }); + } + + private static async Task> GetTransactionMetricsAsync(MetricFilter filter, CloudTable table, IEnumerable metricNames, string invocationId) + { + // Get relevant dimensions + IEnumerable metricDimensions = filter.DimensionFilters.Where(df => metricNames.Contains(df.Name)); + + // Get appropriate entities from table + IEnumerable entities = await GetEntitiesAsync(table, GetTransactionQuery(filter, GetOperationNameForQuery( + metricDimensions, + StorageConstants.Dimensions.ApiDimensionName, + StorageConstants.Dimensions.ApiDimensionAggregateValue)), invocationId); + + // Construct Metrics and accumulate results + return metricDimensions + .Select(md => CreateTransactionMetric(filter, md, entities)) + .Aggregate,IEnumerable>(new Metric[0], (a, b) => a.Union(b)); + } + + private static string GetOperationNameForQuery(IEnumerable dimensions, string dimensionName, string dimensionAggregateValue) + { + string operationName = null; + + // Long story short: look at all the dimension values for all the metrics (dimensions) for the dimensionName, + // if any two dimensionValues are specified, we will get all the dimensions (rows) and filter afterward (return null), + // if no dimension values are specified anywhere, only get the "Aggregate" row (specified by dimensionAggregateValue), + // if exactly one dimension value is specified across all the metrics, get only that row + foreach (string value in + from d in dimensions + where d.Dimensions != null + select d.Dimensions.FirstOrDefault(fd => string.Equals(fd.Name, dimensionName)) + into filterDimension + where filterDimension != null + from value in filterDimension.Values + select value) + { + if (operationName == null) + { + operationName = value; + } + else if (!string.Equals(operationName, value, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + } + + return operationName ?? dimensionAggregateValue; + } + + private static IEnumerable CreateTransactionMetric(MetricFilter filter, MetricDimension metricDimension, IEnumerable entities) + { + List metrics = new List(); + + // Get supported metric dimension + FilterDimension filterDimension = metricDimension.Dimensions == null + ? null + : metricDimension.Dimensions.FirstOrDefault(fd => + string.Equals(fd.Name, StorageConstants.Dimensions.ApiDimensionName, StringComparison.OrdinalIgnoreCase)); + + // no dimensions (or no supported dimensions) means only get aggregate values (user;All) + if (filterDimension == null) + { + metrics.Add(new Metric() + { + Name = new LocalizableString() + { + Value = metricDimension.Name, + LocalizedValue = metricDimension.Name + }, + StartTime = filter.StartTime, + EndTime = filter.EndTime, + TimeGrain = filter.TimeGrain, + Properties = new Dictionary(), + MetricValues = entities + .Where(e => string.Equals(e.RowKey, GetTransactionRowKey(StorageConstants.Dimensions.ApiDimensionAggregateValue), StringComparison.OrdinalIgnoreCase)) + .Select(e => GetMetricValueFromEntity(e, metricDimension.Name)).ToList() + }); + } + + // Dimension specified, get samples with requested dimension value + else + { + // This is the function for filtering based on dimension value (row key) + Func, bool> groupFilter; + + // dimension specified, but no values means get all and group by dimension value (row key) + if (filterDimension.Values == null || !filterDimension.Values.Any()) + { + // select all groups, but leave off aggregate. Each group becomes one metric + groupFilter = (entityGroup) => + !string.Equals(entityGroup.Key, GetTransactionRowKey(StorageConstants.Dimensions.ApiDimensionName), StringComparison.OrdinalIgnoreCase); + } + else + { + // select only groups specified by dimension values + groupFilter = (entityGroup) => filterDimension.Values.Select(GetTransactionRowKey).Contains(entityGroup.Key); + } + + // Construct and add the metrics to the collection to return + metrics.AddRange(entities + .GroupBy(e => e.RowKey) + .Where(groupFilter) + .Select(entityGroup => new Metric() + { + Name = new LocalizableString() + { + Value = metricDimension.Name, + LocalizedValue = metricDimension.Name + }, + StartTime = filter.StartTime, + EndTime = filter.EndTime, + TimeGrain = filter.TimeGrain, + Properties = new Dictionary(), + MetricValues = entityGroup.Select(e => GetMetricValueFromEntity(e, metricDimension.Name)).ToList() + })); + } + + // return only values specified + return metrics; + } + + private static string GetTransactionRowKey(string dimensionValue) + { + return string.Concat(StorageConstants.Dimensions.TransactionDataTypeDimensionUserValue, ";", dimensionValue); + } + + // Copied from Microsoft.WindowsAzure.Management.Monitoring.ResourceProviders.Storage.Rest.V2011_12.MetricBaseController + private static TableQuery GetTransactionQuery(MetricFilter filter, string operationName = null) + { + // storage transaction queries are only supported for 1 hr and 1 min timegrains + if (filter.TimeGrain != StorageConstants.PT1H && filter.TimeGrain != StorageConstants.PT1M) + { + return null; + } + + DateTime partitionKeyStartTime = filter.StartTime; + DateTime partitionKeyEndTime = filter.EndTime; + + // start by assuming that we are querying for hr metrics + // since the timestamp field does not represent the actual sample period the only time value represented is the partitionkey + // this is basically truncated to the hr with the min zeroed out. + string startKey = partitionKeyStartTime.ToString("yyyyMMddTHH00"); + string endKey = partitionKeyEndTime.ToString("yyyyMMddTHH00"); + + // if this is actually a minute metric request correct the partition keys and table name format + if (filter.TimeGrain == TimeSpan.FromMinutes(1)) + { + startKey = partitionKeyStartTime.ToString("yyyyMMddTHHmm"); + endKey = partitionKeyEndTime.ToString("yyyyMMddTHHmm"); + } + + string rowKey = "user;"; + string rowComparison = QueryComparisons.Equal; + + // If requesting a particular operation, get only that one (dimension value), otherwise get all + if (operationName == null) + { + rowComparison = QueryComparisons.GreaterThanOrEqual; + } + else + { + rowKey += operationName; + } + + var filter1 = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.GreaterThanOrEqual, startKey); + var filter2 = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.LessThanOrEqual, endKey); + var filter3 = TableQuery.GenerateFilterCondition("RowKey", rowComparison, rowKey); + + var tableQuery = new TableQuery().Where( + TableQuery.CombineFilters(TableQuery.CombineFilters(filter1, TableOperators.And, filter2), TableOperators.And, filter3)); + + return tableQuery; + } + + // Copied from Microsoft.WindowsAzure.Management.Monitoring.ResourceProviders.Storage.Rest.V2011_12.MetricBaseController + private static TableQuery GetCapacityQuery(MetricFilter filter) + { + // capacity only applies for blob service and only for a timegrain of 1 day + if (filter.TimeGrain != TimeSpan.FromDays(1)) + { + return null; + } + + // since the timestamp field does not represent the actual sample period the only time vaule represented is the partitionkey + // this is basically truncated to the hr with the min zeroed out. + DateTime partitionKeyStartTime = filter.StartTime; + DateTime partitionKeyEndTime = filter.EndTime; + + string startKey = partitionKeyStartTime.ToString("yyyyMMddTHH00"); + string endKey = partitionKeyEndTime.ToString("yyyyMMddTHH00"); + + var filter1 = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.GreaterThanOrEqual, startKey); + var filter2 = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.LessThanOrEqual, endKey); + var filter3 = TableQuery.GenerateFilterCondition("RowKey", QueryComparisons.Equal, "data"); + + var tableQuery = new TableQuery() + .Where(TableQuery.CombineFilters(TableQuery.CombineFilters(filter1, TableOperators.And, filter2), TableOperators.And, filter3)); + + return tableQuery; + } + } +} diff --git a/src/Insights/Insights.csproj b/src/Insights/Insights.csproj index 1eacb6b8f81c..86cfe489b121 100644 --- a/src/Insights/Insights.csproj +++ b/src/Insights/Insights.csproj @@ -24,15 +24,22 @@ + + + + + + +