diff --git a/src/Elastic.Apm/Cloud/AzureAppServiceMetadataProvider.cs b/src/Elastic.Apm/Cloud/AzureAppServiceMetadataProvider.cs index c500f2ed0..947440e59 100644 --- a/src/Elastic.Apm/Cloud/AzureAppServiceMetadataProvider.cs +++ b/src/Elastic.Apm/Cloud/AzureAppServiceMetadataProvider.cs @@ -3,6 +3,7 @@ // 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 System.Threading.Tasks; using Elastic.Apm.Api; @@ -86,6 +87,11 @@ bool NullOrEmptyVariable(string key, string value) } var subscriptionId = websiteOwnerNameParts[0]; + + var webspaceIndex = websiteOwnerNameParts[1].LastIndexOf(Webspace, StringComparison.Ordinal); + if (webspaceIndex != -1) + websiteOwnerNameParts[1] = websiteOwnerNameParts[1].Substring(0, webspaceIndex); + var lastHyphenIndex = websiteOwnerNameParts[1].LastIndexOf('-'); if (lastHyphenIndex == -1) { @@ -96,11 +102,7 @@ bool NullOrEmptyVariable(string key, string value) 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); + var region = websiteOwnerNameParts[1].Substring(lastHyphenIndex + 1); return Task.FromResult(new Api.Cloud { diff --git a/src/Elastic.Apm/Cloud/CloudMetadataProviderCollection.cs b/src/Elastic.Apm/Cloud/CloudMetadataProviderCollection.cs index ac66f5d7c..c1b3af4fd 100644 --- a/src/Elastic.Apm/Cloud/CloudMetadataProviderCollection.cs +++ b/src/Elastic.Apm/Cloud/CloudMetadataProviderCollection.cs @@ -20,7 +20,14 @@ public class CloudMetadataProviderCollection : KeyedCollection item.Provider; public CloudMetadataProviderCollection(string cloudProvider, IApmLogger logger) + : this(cloudProvider, logger, new EnvironmentVariables(logger)) { + } + + internal CloudMetadataProviderCollection(string cloudProvider, IApmLogger logger, IEnvironmentVariables environmentVariables) + { + environmentVariables ??= new EnvironmentVariables(logger); + switch (cloudProvider?.ToLowerInvariant()) { case SupportedValues.CloudProviderAws: @@ -31,7 +38,7 @@ public CloudMetadataProviderCollection(string cloudProvider, IApmLogger logger) break; case SupportedValues.CloudProviderAzure: Add(new AzureCloudMetadataProvider(logger)); - Add(new AzureAppServiceMetadataProvider(logger, EnvironmentHelper.GetEnvironmentVariables(logger))); + Add(new AzureAppServiceMetadataProvider(logger, environmentVariables.GetEnvironmentVariables())); break; case SupportedValues.CloudProviderNone: break; @@ -42,7 +49,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))); + Add(new AzureAppServiceMetadataProvider(logger, environmentVariables.GetEnvironmentVariables())); 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 deleted file mode 100644 index 28a0a35f2..000000000 --- a/src/Elastic.Apm/Helpers/EnvironmentHelper.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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/src/Elastic.Apm/Helpers/EnvironmentVariables.cs b/src/Elastic.Apm/Helpers/EnvironmentVariables.cs new file mode 100644 index 000000000..1155d8bfd --- /dev/null +++ b/src/Elastic.Apm/Helpers/EnvironmentVariables.cs @@ -0,0 +1,35 @@ +// 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 +{ + internal interface IEnvironmentVariables + { + IDictionary GetEnvironmentVariables(); + } + + internal sealed class EnvironmentVariables : IEnvironmentVariables + { + private readonly IApmLogger _logger; + public EnvironmentVariables(IApmLogger logger) => _logger = logger.Scoped(nameof(EnvironmentVariables)); + + public IDictionary GetEnvironmentVariables() + { + try + { + return Environment.GetEnvironmentVariables(); + } + catch (Exception e) + { + _logger.Error()?.LogException(e, "could not get environment variables"); + return null; + } + } + } +} diff --git a/src/Elastic.Apm/Report/PayloadSenderV2.cs b/src/Elastic.Apm/Report/PayloadSenderV2.cs index c384132b3..a96edf6a0 100644 --- a/src/Elastic.Apm/Report/PayloadSenderV2.cs +++ b/src/Elastic.Apm/Report/PayloadSenderV2.cs @@ -61,7 +61,8 @@ public PayloadSenderV2( IApmServerInfo apmServerInfo, HttpMessageHandler httpMessageHandler = null, string dbgName = null, - bool isEnabled = true + bool isEnabled = true, + IEnvironmentVariables environmentVariables = null ) : base(isEnabled, logger, ThisClassName, service, config, httpMessageHandler) { @@ -76,7 +77,7 @@ public PayloadSenderV2( System = system; - _cloudMetadataProviderCollection = new CloudMetadataProviderCollection(config.CloudProvider, _logger); + _cloudMetadataProviderCollection = new CloudMetadataProviderCollection(config.CloudProvider, _logger, environmentVariables); _apmServerInfo = apmServerInfo; _metadata = new Metadata { Service = service, System = System }; foreach (var globalLabelKeyValue in config.GlobalLabels) _metadata.Labels.Add(globalLabelKeyValue.Key, globalLabelKeyValue.Value); diff --git a/test/Elastic.Apm.Feature.Tests/CloudProviderSteps.cs b/test/Elastic.Apm.Feature.Tests/CloudProviderSteps.cs new file mode 100644 index 000000000..38fa14d7a --- /dev/null +++ b/test/Elastic.Apm.Feature.Tests/CloudProviderSteps.cs @@ -0,0 +1,152 @@ +// 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.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using Elastic.Apm.Api; +using Elastic.Apm.Logging; +using Elastic.Apm.Report; +using Elastic.Apm.Tests.Utilities; +using Elastic.Apm.Tests.Utilities.XUnit; +using FluentAssertions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using RichardSzalay.MockHttp; +using TechTalk.SpecFlow; +using Xunit.Abstractions; +using static Elastic.Apm.BackendComm.BackendCommUtils.ApmServerEndpoints; +using MockHttpMessageHandler = RichardSzalay.MockHttp.MockHttpMessageHandler; + +namespace Elastic.Apm.Feature.Tests +{ + [Binding] + public class CloudProviderSteps + { + private static readonly JsonSerializer _serializer = new JsonSerializer(); + private readonly ScenarioContext _scenarioContext; + + public CloudProviderSteps(ScenarioContext scenarioContext) => _scenarioContext = scenarioContext; + + [Given(@"^an instrumented application is configured to collect cloud provider metadata for (.*?)$")] + public void AgentWithCloudMetadata(string cloudProvider) + { + var output = _scenarioContext.ScenarioContainer.Resolve(); + var logger = new XUnitLogger(LogLevel.Trace, output); + var config = new MockConfigSnapshot(logger, cloudProvider: cloudProvider); + + var payloadCollector = new PayloadCollector(); + _scenarioContext.Set(payloadCollector); + + var handler = new MockHttpMessageHandler(); + handler.When(BuildIntakeV2EventsAbsoluteUrl(config.ServerUrl).AbsoluteUri) + .Respond(r => + { + var json = r.Content.ReadAsStringAsync().Result; + if (json.Contains("\"metadata\"")) + { + payloadCollector.Request = ParseJObjects(json); + payloadCollector.WaitHandle.Set(); + } + + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var environmentVariables = new TestEnvironmentVariables(); + _scenarioContext.Set(environmentVariables); + + var payloadSender = new PayloadSenderV2( + logger, + config, + Service.GetDefaultService(config, new NoopLogger()), + new Api.System(), + MockApmServerInfo.Version710, + handler, + environmentVariables: environmentVariables); + + var lazyAgent = new Lazy(() => + new ApmAgent(new TestAgentComponents(logger, config, payloadSender))); + + _scenarioContext.Set(lazyAgent); + } + + [Given("^the following environment variables are present$")] + public void EnvironmentVariablesSet(Table table) + { + var environmentVariables = _scenarioContext.Get(); + + foreach(var row in table.Rows) + environmentVariables[row[0]] = row[1]; + } + + [When("^cloud metadata is collected$")] + public void CollectCloudMetadata() + { + var lazyAgent = _scenarioContext.Get>(); + // create the agent and capture a transaction to send metadata + var agent = lazyAgent.Value; + agent.Tracer.CaptureTransaction("Transaction", "feature", () => { }); + + var payloadCollector = _scenarioContext.Get(); + + // wait for the wait handle to be signalled + var timeout = TimeSpan.FromSeconds(30); + if (!payloadCollector.WaitHandle.Wait(timeout)) + throw new Exception($"Did not receive payload within {timeout}"); + } + + [Then("^cloud metadata is not null$")] + public void CloudMetadataIsNotNull() + { + var payloadCollector = _scenarioContext.Get(); + + payloadCollector.Request.Should().NotBeNull(); + var cloudMetadata = payloadCollector.Request[0]["metadata"]["cloud"]; + cloudMetadata.Should().NotBeNull(); + } + + [Then("^cloud metadata is null$")] + public void CloudMetadataIsNull() + { + var payloadCollector = _scenarioContext.Get(); + + payloadCollector.Request.Should().NotBeNull(); + var cloudMetadata = payloadCollector.Request[0]["metadata"]["cloud"]; + cloudMetadata.Should().BeNull(); + } + + [Then("^cloud metadata '(.*?)' is '(.*?)'$")] + public void CloudMetadataKeyEqualsValue(string key, string value) + { + var payloadCollector = _scenarioContext.Get(); + var token = payloadCollector.Request[0].SelectToken($"metadata.cloud.{key}"); + + token.Should().NotBeNull(); + token.Value().Should().Be(value); + } + + private class PayloadCollector + { + public ManualResetEventSlim WaitHandle { get; } + + public PayloadCollector() => WaitHandle = new ManualResetEventSlim(false); + + public List Request { get; set; } + } + + private static List ParseJObjects(string json) + { + var jObjects = new List(); + using var stringReader = new StringReader(json); + using var jsonReader = new JsonTextReader(stringReader) { SupportMultipleContent = true }; + while (jsonReader.Read()) + jObjects.Add(_serializer.Deserialize(jsonReader)); + return jObjects; + } + } +} diff --git a/test/Elastic.Apm.Feature.Tests/Elastic.Apm.Feature.Tests.csproj b/test/Elastic.Apm.Feature.Tests/Elastic.Apm.Feature.Tests.csproj index 68fb4f407..07c8d9234 100644 --- a/test/Elastic.Apm.Feature.Tests/Elastic.Apm.Feature.Tests.csproj +++ b/test/Elastic.Apm.Feature.Tests/Elastic.Apm.Feature.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/test/Elastic.Apm.Feature.Tests/Features/azure_app_service_metadata.feature b/test/Elastic.Apm.Feature.Tests/Features/azure_app_service_metadata.feature new file mode 100644 index 000000000..3149e8719 --- /dev/null +++ b/test/Elastic.Apm.Feature.Tests/Features/azure_app_service_metadata.feature @@ -0,0 +1,71 @@ +Feature: Extracting Metadata for Azure App Service + + Background: + Given an instrumented application is configured to collect cloud provider metadata for azure + + Scenario Outline: Azure App Service with all environment variables present in expected format + Given the following environment variables are present + | name | value | + | WEBSITE_OWNER_NAME | | + | WEBSITE_RESOURCE_GROUP | resource_group | + | WEBSITE_SITE_NAME | site_name | + | WEBSITE_INSTANCE_ID | instance_id | + When cloud metadata is collected + Then cloud metadata is not null + And cloud metadata 'account.id' is 'f5940f10-2e30-3e4d-a259-63451ba6dae4' + And cloud metadata 'provider' is 'azure' + And cloud metadata 'instance.id' is 'instance_id' + And cloud metadata 'instance.name' is 'site_name' + And cloud metadata 'project.name' is 'resource_group' + And cloud metadata 'region' is 'AustraliaEast' + Examples: + | WEBSITE_OWNER_NAME | + | f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace | + | f5940f10-2e30-3e4d-a259-63451ba6dae4+appsvc_linux_australiaeast-AustraliaEastwebspace-Linux | + + # WEBSITE_OWNER_NAME is expected to include a + character + Scenario: WEBSITE_OWNER_NAME environment variable not expected format + Given the following environment variables are present + | name | value | + | WEBSITE_OWNER_NAME | f5940f10-2e30-3e4d-a259-63451ba6dae4-elastic-apm-AustraliaEastwebspace | + | WEBSITE_RESOURCE_GROUP | resource_group | + | WEBSITE_SITE_NAME | site_name | + | WEBSITE_INSTANCE_ID | instance_id | + When cloud metadata is collected + Then cloud metadata is null + + Scenario: Missing WEBSITE_OWNER_NAME environment variable + Given the following environment variables are present + | name | value | + | WEBSITE_RESOURCE_GROUP | resource_group | + | WEBSITE_SITE_NAME | site_name | + | WEBSITE_INSTANCE_ID | instance_id | + When cloud metadata is collected + Then cloud metadata is null + + Scenario: Missing WEBSITE_RESOURCE_GROUP environment variable + Given the following environment variables are present + | name | value | + | WEBSITE_OWNER_NAME | f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace | + | WEBSITE_SITE_NAME | site_name | + | WEBSITE_INSTANCE_ID | instance_id | + When cloud metadata is collected + Then cloud metadata is null + + Scenario: Missing WEBSITE_SITE_NAME environment variable + Given the following environment variables are present + | name | value | + | WEBSITE_OWNER_NAME | f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace | + | WEBSITE_RESOURCE_GROUP | resource_group | + | WEBSITE_INSTANCE_ID | instance_id | + When cloud metadata is collected + Then cloud metadata is null + + Scenario: Missing WEBSITE_INSTANCE_ID environment variable + Given the following environment variables are present + | name | value | + | WEBSITE_OWNER_NAME | f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace | + | WEBSITE_RESOURCE_GROUP | resource_group | + | WEBSITE_SITE_NAME | site_name | + When cloud metadata is collected + Then cloud metadata is null \ No newline at end of file diff --git a/test/Elastic.Apm.Tests.Utilities/TestEnvironmentVariables.cs b/test/Elastic.Apm.Tests.Utilities/TestEnvironmentVariables.cs new file mode 100644 index 000000000..b0d824a5d --- /dev/null +++ b/test/Elastic.Apm.Tests.Utilities/TestEnvironmentVariables.cs @@ -0,0 +1,25 @@ +// 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 Elastic.Apm.Helpers; + +namespace Elastic.Apm.Tests.Utilities +{ + public class TestEnvironmentVariables: IEnvironmentVariables + { + private readonly Hashtable _hashTable; + + public TestEnvironmentVariables() => _hashTable = new Hashtable(); + + public string this[string key] + { + get => _hashTable[key] as string; + set => _hashTable[key] = value; + } + + public IDictionary GetEnvironmentVariables() => _hashTable; + } +}