diff --git a/src/Elastic.Apm/Cloud/AzureAppServiceMetadataProvider.cs b/src/Elastic.Apm/Cloud/AzureAppServiceMetadataProvider.cs new file mode 100644 index 000000000..c500f2ed0 --- /dev/null +++ b/src/Elastic.Apm/Cloud/AzureAppServiceMetadataProvider.cs @@ -0,0 +1,120 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Collections; +using System.Threading.Tasks; +using Elastic.Apm.Api; +using Elastic.Apm.Logging; + +namespace Elastic.Apm.Cloud +{ + /// + /// Provides cloud metadata for Microsoft Azure App Services + /// + public class AzureAppServiceMetadataProvider : ICloudMetadataProvider + { + internal const string Name = "azure-app-service"; + + private readonly IApmLogger _logger; + private readonly IDictionary _environmentVariables; + + /// + /// Value of the form {subscription id}+{app service plan resource group}-{region}webspace + /// + /// + /// f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace + /// + internal static readonly string WebsiteOwnerName = "WEBSITE_OWNER_NAME"; + + internal static readonly string WebsiteResourceGroup = "WEBSITE_RESOURCE_GROUP"; + + internal static readonly string WebsiteSiteName = "WEBSITE_SITE_NAME"; + + internal static readonly string WebsiteInstanceId = "WEBSITE_INSTANCE_ID"; + + private static readonly string Webspace = "webspace"; + + public AzureAppServiceMetadataProvider(IApmLogger logger, IDictionary environmentVariables) + { + _logger = logger; + _environmentVariables = environmentVariables; + } + + public string Provider { get; } = Name; + + public Task GetMetadataAsync() + { + if (_environmentVariables is null) + { + _logger.Trace()?.Log("Unable to get {Provider} cloud metadata as no environment variables available", Provider); + return Task.FromResult(null); + } + + var websiteOwnerName = GetEnvironmentVariable(WebsiteOwnerName); + var websiteResourceGroup = GetEnvironmentVariable(WebsiteResourceGroup); + var websiteSiteName = GetEnvironmentVariable(WebsiteSiteName); + var websiteInstanceId = GetEnvironmentVariable(WebsiteInstanceId); + + bool NullOrEmptyVariable(string key, string value) + { + if (!string.IsNullOrEmpty(value)) return false; + + _logger.Trace()?.Log( + "Unable to get {Provider} cloud metadata as no {EnvironmentVariable} environment variable", + Provider, + key); + + return true; + } + + if (NullOrEmptyVariable(WebsiteOwnerName, websiteOwnerName) || + NullOrEmptyVariable(WebsiteResourceGroup, websiteResourceGroup) || + NullOrEmptyVariable(WebsiteSiteName, websiteSiteName) || + NullOrEmptyVariable(WebsiteInstanceId, websiteInstanceId)) + return Task.FromResult(null); + + var websiteOwnerNameParts = websiteOwnerName.Split('+'); + if (websiteOwnerNameParts.Length != 2) + { + _logger.Trace()?.Log( + "Unable to get {Provider} cloud metadata as {EnvironmentVariable} does not contain expected format", + Provider, + WebsiteOwnerName); + return Task.FromResult(null); + } + + var subscriptionId = websiteOwnerNameParts[0]; + var lastHyphenIndex = websiteOwnerNameParts[1].LastIndexOf('-'); + if (lastHyphenIndex == -1) + { + _logger.Trace()?.Log( + "Unable to get {Provider} cloud metadata as {EnvironmentVariable} does not contain expected format", + Provider, + WebsiteOwnerName); + return Task.FromResult(null); + } + + var index = lastHyphenIndex + 1; + + var region = websiteOwnerNameParts[1].EndsWith(Webspace) + ? websiteOwnerNameParts[1].Substring(index, websiteOwnerNameParts[1].Length - (index + Webspace.Length)) + : websiteOwnerNameParts[1].Substring(index); + + return Task.FromResult(new Api.Cloud + { + Account = new CloudAccount { Id = subscriptionId }, + Instance = new CloudInstance { Id = websiteInstanceId, Name = websiteSiteName }, + Project = new CloudProject { Name = websiteResourceGroup }, + Provider = "azure", + Region = region + }); + } + + private string GetEnvironmentVariable(string key) => + _environmentVariables.Contains(key) + ? _environmentVariables[key]?.ToString() + : null; + } +} diff --git a/src/Elastic.Apm/Cloud/CloudMetadataProviderCollection.cs b/src/Elastic.Apm/Cloud/CloudMetadataProviderCollection.cs index 614b77e4e..ac66f5d7c 100644 --- a/src/Elastic.Apm/Cloud/CloudMetadataProviderCollection.cs +++ b/src/Elastic.Apm/Cloud/CloudMetadataProviderCollection.cs @@ -6,6 +6,7 @@ using System; using System.Collections.ObjectModel; using System.Threading.Tasks; +using Elastic.Apm.Helpers; using Elastic.Apm.Logging; using static Elastic.Apm.Config.ConfigConsts; @@ -30,6 +31,7 @@ public CloudMetadataProviderCollection(string cloudProvider, IApmLogger logger) break; case SupportedValues.CloudProviderAzure: Add(new AzureCloudMetadataProvider(logger)); + Add(new AzureAppServiceMetadataProvider(logger, EnvironmentHelper.GetEnvironmentVariables(logger))); break; case SupportedValues.CloudProviderNone: break; @@ -40,6 +42,7 @@ public CloudMetadataProviderCollection(string cloudProvider, IApmLogger logger) Add(new AwsCloudMetadataProvider(logger)); Add(new GcpCloudMetadataProvider(logger)); Add(new AzureCloudMetadataProvider(logger)); + Add(new AzureAppServiceMetadataProvider(logger, EnvironmentHelper.GetEnvironmentVariables(logger))); break; default: throw new ArgumentException($"Unknown cloud provider {cloudProvider}", nameof(cloudProvider)); diff --git a/src/Elastic.Apm/Helpers/EnvironmentHelper.cs b/src/Elastic.Apm/Helpers/EnvironmentHelper.cs new file mode 100644 index 000000000..28a0a35f2 --- /dev/null +++ b/src/Elastic.Apm/Helpers/EnvironmentHelper.cs @@ -0,0 +1,31 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Collections; +using Elastic.Apm.Logging; + +namespace Elastic.Apm.Helpers +{ + /// + /// Gets Environment variables, catching and logging any exception that may be thrown. + /// + internal static class EnvironmentHelper + { + public static IDictionary GetEnvironmentVariables(IApmLogger logger) + { + try + { + return Environment.GetEnvironmentVariables(); + } + catch (Exception e) + { + logger.Debug()?.LogException(e, "Error while getting environment variables"); + } + + return null; + } + } +} diff --git a/test/Elastic.Apm.Tests/Cloud/AzureAppServiceMetadataProviderTests.cs b/test/Elastic.Apm.Tests/Cloud/AzureAppServiceMetadataProviderTests.cs new file mode 100644 index 000000000..87f459f00 --- /dev/null +++ b/test/Elastic.Apm.Tests/Cloud/AzureAppServiceMetadataProviderTests.cs @@ -0,0 +1,74 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Collections; +using System.Threading.Tasks; +using Elastic.Apm.Cloud; +using Elastic.Apm.Tests.Mocks; +using FluentAssertions; +using Xunit; + +namespace Elastic.Apm.Tests.Cloud +{ + public class AzureAppServiceMetadataProviderTests + { + [Fact] + public async Task GetMetadataAsync_Returns_Expected_Cloud_Metadata() + { + var environmentVariables = new Hashtable + { + { AzureAppServiceMetadataProvider.WebsiteInstanceId, "instance_id" }, + { AzureAppServiceMetadataProvider.WebsiteOwnerName, "f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace" }, + { AzureAppServiceMetadataProvider.WebsiteSiteName, "site_name" }, + { AzureAppServiceMetadataProvider.WebsiteResourceGroup, "resource_group" }, + }; + + var provider = new AzureAppServiceMetadataProvider(new NoopLogger(), environmentVariables); + var metadata = await provider.GetMetadataAsync(); + + metadata.Should().NotBeNull(); + metadata.Account.Should().NotBeNull(); + metadata.Account.Id.Should().Be("f5940f10-2e30-3e4d-a259-63451ba6dae4"); + metadata.Provider.Should().Be("azure"); + metadata.Instance.Should().NotBeNull(); + metadata.Instance.Id.Should().Be("instance_id"); + metadata.Instance.Name.Should().Be("site_name"); + metadata.Project.Should().NotBeNull(); + metadata.Project.Name.Should().Be("resource_group"); + metadata.Region.Should().Be("AustraliaEast"); + } + + [Theory] + [InlineData(null, "f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace", "site_name", "resource_group")] + [InlineData("instance_id", null, "site_name", "resource_group")] + [InlineData("instance_id", "f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace", null, "resource_group")] + [InlineData("instance_id", "f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace", "site_name", null)] + public async Task GetMetadataAsync_Returns_Null_When_Expected_EnvironmentVariable_Is_Missing( + string instanceId, string ownerName, string siteName, string resourceGroup) + { + var environmentVariables = new Hashtable + { + { AzureAppServiceMetadataProvider.WebsiteInstanceId, instanceId }, + { AzureAppServiceMetadataProvider.WebsiteOwnerName, ownerName }, + { AzureAppServiceMetadataProvider.WebsiteSiteName, siteName }, + { AzureAppServiceMetadataProvider.WebsiteResourceGroup, resourceGroup }, + }; + + var provider = new AzureAppServiceMetadataProvider(new NoopLogger(), environmentVariables); + var metadata = await provider.GetMetadataAsync(); + + metadata.Should().BeNull(); + } + + [Fact] + public async Task GetMetadataAsync_Returns_Null_When_EnvironmentVariables_Is_Null() + { + var provider = new AzureAppServiceMetadataProvider(new NoopLogger(), null); + var metadata = await provider.GetMetadataAsync(); + + metadata.Should().BeNull(); + } + } +} diff --git a/test/Elastic.Apm.Tests/Cloud/CloudMetadataProviderCollectionTests.cs b/test/Elastic.Apm.Tests/Cloud/CloudMetadataProviderCollectionTests.cs index 70bce09b0..ffae9fa6e 100644 --- a/test/Elastic.Apm.Tests/Cloud/CloudMetadataProviderCollectionTests.cs +++ b/test/Elastic.Apm.Tests/Cloud/CloudMetadataProviderCollectionTests.cs @@ -19,11 +19,12 @@ public void DefaultCloudProvider_Registers_Aws_Gcp_Azure_Providers() { var providers = new CloudMetadataProviderCollection(DefaultValues.CloudProvider, new NoopLogger()); - providers.Count.Should().Be(3); + providers.Count.Should().Be(4); providers.TryGetValue(AwsCloudMetadataProvider.Name, out _).Should().BeTrue(); providers.TryGetValue(GcpCloudMetadataProvider.Name, out _).Should().BeTrue(); providers.TryGetValue(AzureCloudMetadataProvider.Name, out _).Should().BeTrue(); - providers.Select(p => p.Provider).Should().Equal("aws", "gcp", "azure"); + providers.TryGetValue(AzureAppServiceMetadataProvider.Name, out _).Should().BeTrue(); + providers.Select(p => p.Provider).Should().Equal("aws", "gcp", "azure", "azure-app-service"); } [Fact] @@ -52,12 +53,15 @@ public void CloudProvider_Gcp_Should_Register_Gcp_Provider() } [Fact] - public void CloudProvider_Azure_Should_Register_Azure_Provider() + public void CloudProvider_Azure_Should_Register_Azure_Providers() { var providers = new CloudMetadataProviderCollection(SupportedValues.CloudProviderAzure, new NoopLogger()); - providers.Count.Should().Be(1); + providers.Count.Should().Be(2); providers.TryGetValue(SupportedValues.CloudProviderAzure, out var provider).Should().BeTrue(); provider.Should().BeOfType(); + + providers.TryGetValue(AzureAppServiceMetadataProvider.Name, out provider).Should().BeTrue(); + provider.Should().BeOfType(); } } }