diff --git a/.ci/docker/sdk-linux/Dockerfile b/.ci/docker/sdk-linux/Dockerfile index ff770aaad..8ae73a692 100644 --- a/.ci/docker/sdk-linux/Dockerfile +++ b/.ci/docker/sdk-linux/Dockerfile @@ -11,7 +11,6 @@ RUN /bin/bash ./dotnet-install.sh --install-dir "${DOTNET_ROOT}" -version "2.1.5 RUN /bin/bash ./dotnet-install.sh --install-dir "${DOTNET_ROOT}" -version "3.0.103" RUN /bin/bash ./dotnet-install.sh --install-dir "${DOTNET_ROOT}" -version "3.1.100" - # Install docker RUN apt update \ && apt-get -qq install -y apt-transport-https ca-certificates curl \ @@ -21,4 +20,10 @@ RUN apt update \ && apt -qq update \ && apt-get -qq install -y docker-ce docker-ce-cli containerd.io \ --no-install-recommends \ - && rm -rf /var/lib/apt/lists/* \ No newline at end of file + && rm -rf /var/lib/apt/lists/* + +# Install terraform +RUN curl -fsSL https://apt.releases.hashicorp.com/gpg | apt-key add - \ + && apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \ + && apt-get update \ + && apt-get install terraform \ No newline at end of file diff --git a/.ci/linux/deploy.sh b/.ci/linux/deploy.sh index 4357c80ef..83f69ce75 100755 --- a/.ci/linux/deploy.sh +++ b/.ci/linux/deploy.sh @@ -18,7 +18,8 @@ declare -a projectsToPublish=( "Elastic.Apm.Extensions.Hosting" "Elastic.Apm.GrpcClient" "Elastic.Apm.Extensions.Logging" -"Elastic.Apm.StackExchange.Redis") +"Elastic.Apm.StackExchange.Redis" +"Elastic.Apm.Azure.ServiceBus") for project in "${projectsToPublish[@]}" do diff --git a/.ci/windows/test-tools.ps1 b/.ci/windows/test-tools.ps1 index 0598f015b..bb8272de4 100644 --- a/.ci/windows/test-tools.ps1 +++ b/.ci/windows/test-tools.ps1 @@ -9,3 +9,9 @@ if (!$codecov) { dotnet tool install -g Codecov.Tool --version 1.2.0 } +# Install terraform +choco install terraform -m -y --no-progress --force -r --version=0.14.8 +if ($LASTEXITCODE -ne 0) { + Write-Host "terraform installation failed." + exit 1 +} diff --git a/.gitignore b/.gitignore index 00692b4d8..34e8fcd7f 100644 --- a/.gitignore +++ b/.gitignore @@ -340,4 +340,14 @@ html_docs build/output/ # Generated .NET core sln file -ElasticApmAgent.NetCore.sln \ No newline at end of file +ElasticApmAgent.NetCore.sln + +# Terraform configuration state files +.terraform +.terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup +.terraform.tfstate.lock.info + +# Azure credentials file +.credentials.json \ No newline at end of file diff --git a/ElasticApmAgent.sln b/ElasticApmAgent.sln index e0e02cba1..1883fd81b 100644 --- a/ElasticApmAgent.sln +++ b/ElasticApmAgent.sln @@ -131,6 +131,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elastic.Apm.Extensions.Logg EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Apm.Extensions.Logging.Tests", "test\Elastic.Apm.Extensions.Logging.Tests\Elastic.Apm.Extensions.Logging.Tests.csproj", "{B235B13F-42AE-42DA-A3C8-20D047F38685}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Apm.Azure.ServiceBus", "src\Elastic.Apm.Azure.ServiceBus\Elastic.Apm.Azure.ServiceBus.csproj", "{1D43C8C5-4116-45C5-9F4B-56C1D926ED29}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Apm.Azure.ServiceBus.Tests", "test\Elastic.Apm.Azure.ServiceBus.Tests\Elastic.Apm.Azure.ServiceBus.Tests.csproj", "{D9CC53B2-5F6B-434B-8689-2350F3A9FB2D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Apm.Azure.ServiceBus.Sample", "sample\Elastic.Apm.Azure.ServiceBus.Sample\Elastic.Apm.Azure.ServiceBus.Sample.csproj", "{27563B4E-ECB1-4F1B-B9F1-22C2C165B270}" +EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution test\Elastic.Apm.DatabaseTests.Common\Elastic.Apm.DatabaseTests.Common.projitems*{968e1e85-e996-42de-9845-d20dae16165a}*SharedItemsImports = 5 @@ -324,6 +330,18 @@ Global {B235B13F-42AE-42DA-A3C8-20D047F38685}.Debug|Any CPU.Build.0 = Debug|Any CPU {B235B13F-42AE-42DA-A3C8-20D047F38685}.Release|Any CPU.ActiveCfg = Release|Any CPU {B235B13F-42AE-42DA-A3C8-20D047F38685}.Release|Any CPU.Build.0 = Release|Any CPU + {1D43C8C5-4116-45C5-9F4B-56C1D926ED29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D43C8C5-4116-45C5-9F4B-56C1D926ED29}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D43C8C5-4116-45C5-9F4B-56C1D926ED29}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D43C8C5-4116-45C5-9F4B-56C1D926ED29}.Release|Any CPU.Build.0 = Release|Any CPU + {D9CC53B2-5F6B-434B-8689-2350F3A9FB2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9CC53B2-5F6B-434B-8689-2350F3A9FB2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9CC53B2-5F6B-434B-8689-2350F3A9FB2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9CC53B2-5F6B-434B-8689-2350F3A9FB2D}.Release|Any CPU.Build.0 = Release|Any CPU + {27563B4E-ECB1-4F1B-B9F1-22C2C165B270}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {27563B4E-ECB1-4F1B-B9F1-22C2C165B270}.Debug|Any CPU.Build.0 = Debug|Any CPU + {27563B4E-ECB1-4F1B-B9F1-22C2C165B270}.Release|Any CPU.ActiveCfg = Release|Any CPU + {27563B4E-ECB1-4F1B-B9F1-22C2C165B270}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -375,6 +393,9 @@ Global {9AE4805D-2586-4FA5-A0D0-885264EBC565} = {267A241E-571F-458F-B04C-B6C4DE79E735} {9BAEEF56-4061-488A-8FB8-28BDBBB26C3D} = {3734A52F-2222-454B-BF58-1BA5C1F29D77} {B235B13F-42AE-42DA-A3C8-20D047F38685} = {267A241E-571F-458F-B04C-B6C4DE79E735} + {1D43C8C5-4116-45C5-9F4B-56C1D926ED29} = {3734A52F-2222-454B-BF58-1BA5C1F29D77} + {D9CC53B2-5F6B-434B-8689-2350F3A9FB2D} = {267A241E-571F-458F-B04C-B6C4DE79E735} + {27563B4E-ECB1-4F1B-B9F1-22C2C165B270} = {3C791D9C-6F19-4F46-B367-2EC0F818762D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {69E02FD9-C9DE-412C-AB6B-5B8BECC6BFA5} diff --git a/build/terraform/azure/service_bus/test_resources.tf b/build/terraform/azure/service_bus/test_resources.tf new file mode 100644 index 000000000..5ca5038d4 --- /dev/null +++ b/build/terraform/azure/service_bus/test_resources.tf @@ -0,0 +1,96 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=2.46.0" + } + } +} + +provider "azurerm" { + features {} +} + +# configuration is sourced from the following environment variables: +# ARM_CLIENT_ID +# ARM_CLIENT_SECRET +# ARM_SUBSCRIPTION_ID +# ARM_TENANT_ID +# +# See https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/service_principal_client_secret +# for creating a Service Principal and Client Secret +data "azurerm_client_config" "current" { +} + +resource "random_uuid" "variables" { +} + +variable "resource_group" { + type = string + description = "The name of the resource group to create" +} + +variable "location" { + type = string + description = "The Azure location in which to deploy resources" + default = "westus" +} + +variable "servicebus_namespace" { + type = string + description = "The name of the servicebus namespace to create" +} + +resource "azurerm_resource_group" "servicebus_resource_group" { + name = var.resource_group + location = var.location +} + +resource "azurerm_servicebus_namespace" "servicebus_namespace" { + location = azurerm_resource_group.servicebus_resource_group.location + name = var.servicebus_namespace + resource_group_name = azurerm_resource_group.servicebus_resource_group.name + sku = "Standard" + depends_on = [azurerm_resource_group.servicebus_resource_group] +} + +# random name to generate for the contributor role assignment +resource "random_uuid" "contributor_role" { + keepers = { + client_id = data.azurerm_client_config.current.client_id + } +} + +resource "azurerm_role_assignment" "contributor_role" { + name = random_uuid.contributor_role.result + principal_id = data.azurerm_client_config.current.object_id + role_definition_name = "Contributor" + scope = azurerm_resource_group.servicebus_resource_group.id + depends_on = [azurerm_servicebus_namespace.servicebus_namespace] +} + +# random name to generate for the contributor role assignment +resource "random_uuid" "data_owner_role" { + keepers = { + client_id = data.azurerm_client_config.current.client_id + } +} + +resource "azurerm_role_assignment" "servicebus_data_owner_role" { + name = random_uuid.data_owner_role.result + principal_id = data.azurerm_client_config.current.object_id + role_definition_name = "Azure Service Bus Data Owner" + scope = azurerm_resource_group.servicebus_resource_group.id + depends_on = [azurerm_servicebus_namespace.servicebus_namespace] +} + +# following role assignment, there can be a delay of up to ~1 minute +# for the assignments to propagate in Azure. You may need to introduce +# a wait before using the Azure resources created. + +output "connection_string" { + value = azurerm_servicebus_namespace.servicebus_namespace.default_primary_connection_string + description = "The service bus primary connection string" + sensitive = true +} + diff --git a/docs/configuration.asciidoc b/docs/configuration.asciidoc index 552ea50aa..2e832a608 100644 --- a/docs/configuration.asciidoc +++ b/docs/configuration.asciidoc @@ -892,10 +892,39 @@ When this setting is `true`, the agent will also add the header `elasticapm-trac | `true` | Boolean |============ +[[config-messaging]] +=== Messaging configuration options + +[float] +[[config-ignore-message-queues]] +==== `IgnoreMessageQueues` (added[1.10]) + +Used to filter out specific messaging queues/topics/exchanges from being traced. When set, sends-to and receives-from the +specified queues/topics/exchanges will be ignored. + +This config accepts a comma separated string of wildcard patterns of queues/topics/exchange names which should be ignored. + +The wildcard, `*`, matches zero or more characters, and matching is case insensitive by default. +Prepending an element with `(?-i)` makes the matching case sensitive. +Examples: `/foo/*/bar/*/baz*`, `*foo*`. + +[options="header"] +|============ +| Default | Type +| | String +|============ + +[options="header"] +|============ +| Environment variable name | IConfiguration or Web.config key +| `ELASTIC_APM_IGNORE_MESSAGE_QUEUES` | `ElasticApm:IgnoreMessageQueues` +|============ + + [[config-stacktrace]] === Stacktrace configuration options -[float] +[float] [[config-application-namespaces]] ==== `ApplicationNamespaces` (added[1.5]) @@ -1040,6 +1069,7 @@ you must instead set the `LogLevel` for the internal APM logger under the `Loggi | <> | No | Stacktrace | <> | No | Reporter | <> | No | Core +| <> | Yes | Messaging, Performance | <> | No | Core | <> | Yes | Supportability | <> | No | Reporter diff --git a/docs/setup.asciidoc b/docs/setup.asciidoc index 37ec3725b..9eb5f5149 100644 --- a/docs/setup.asciidoc +++ b/docs/setup.asciidoc @@ -14,6 +14,7 @@ On .NET Core the agent also supports auto instrumentation without any code chang * <> * <> * <> +* <> * <> [float] @@ -53,6 +54,14 @@ https://www.nuget.org/packages/Elastic.Apm.SqlClient[**Elastic.Apm.SqlClient**]: This package contains https://www.nuget.org/packages/System.Data.SqlClient[System.Data.SqlClient] and https://www.nuget.org/packages/Microsoft.Data.SqlClient[Microsoft.Data.SqlClient] monitoring related code. +https://www.nuget.org/packages/Elastic.Apm.StackExchange.Redis[**Elastic.Apm.StackExchange.Redis**]:: + +This packages contains instrumentation to capture spans for commands sent to redis with https://www.nuget.org/packages/StackExchange.Redis/[StackExchange.Redis] package. + +https://www.nuget.org/packages/Elastic.Apm.StackExchange.Redis[**Elastic.Apm.Azure.ServiceBus**]:: + +This packages contains instrumentation to capture transactions and spans for messages sent and received from Azure Service Bus with https://www.nuget.org/packages/Microsoft.Azure.ServiceBus/[Microsoft.Azure.ServiceBus] and https://www.nuget.org/packages/Azure.Messaging.ServiceBus/[Azure.Messaging.ServiceBus] packages. + [[setup-dotnet-net-core]] === .NET Core @@ -361,6 +370,39 @@ connection.UseElasticApm(); A callback is registered with the `IConnectionMultiplexer` to provide a profiling session for each transaction and span that captures redis commands sent with `IConnectionMultiplexer`. +[[setup-azure-servicebus]] +=== Azure Service Bus + +[float] +==== Quick start + +Instrumentation can be enabled for Azure Service Bus by referencing https://www.nuget.org/packages/Elastic.Apm.Azure.ServiceBus[`Elastic.Apm.Azure.ServiceBus`] package and subscribing to diagnostic events +using one of the subscribers: + +. If the agent is included by referencing the `Elastic.Apm.NetCoreAll` package, the subscribers will be automatically subscribed with the agent, and no further action is required. +. If you're using `Microsoft.Azure.ServiceBus`, subscribe `MicrosoftAzureServiceBusDiagnosticsSubscriber` with the agent ++ +[source, csharp] +---- +Agent.Subscribe(new MicrosoftAzureServiceBusDiagnosticsSubscriber()); +---- +. If you're using `Azure.Messaging.ServiceBus`, subscribe `AzureMessagingServiceBusDiagnosticsSubscriber` with the agent ++ +[source, csharp] +---- +Agent.Subscribe(new AzureMessagingServiceBusDiagnosticsSubscriber()); +---- + +A new transaction is created when + +* one or more messages are received from a queue or topic subscription. +* a message is receive deferred from a queue or topic subscription. + +A new span is created when there is a current transaction, and when + +* one or more messages are sent to a queue or topic. +* one or more messages are scheduled to a queue or a topic. + [[setup-general]] === Other .NET applications diff --git a/docs/supported-technologies.asciidoc b/docs/supported-technologies.asciidoc index eb5dcaafe..bd145e3cb 100644 --- a/docs/supported-technologies.asciidoc +++ b/docs/supported-technologies.asciidoc @@ -64,7 +64,7 @@ Streaming is not supported. In practice this means for streaming use-cases the a |Framework |Supported versions |Supported since agent's version |gRPC on .NET Core -|2.23.2 and later +|2.23.2+ |1.7 |=== @@ -78,24 +78,24 @@ We support automatic instrumentation for the following data access technologies. |Data access technology |Supported versions |Notes |Supported since agent's version |Entity Framework (EF) Core -|2.x +|2.x+ |A DB span is automatically created for each access to underlying database performed by Entity Framework Core. |1.0 |Entity Framework (EF) 6 -|6.2 and later +|6.2+ |A DB span is automatically created for each access to underlying database performed by Entity Framework 6. |1.2 | Elasticsearch (Elasticsearch.Net and NEST) -| 7.6.0 +| 7.6.0+ | __If you're using 7.10.1 or 7.11.0, upgrade to at least 7.11.1 which fixes a bug in capture__ -| 1.6.0 +| 1.6 | Redis (StackExchange.Redis) -| 2.0.495 +| 2.0.495+ | A DB span is automatically created for each profiled redis command peformed by StackExchange.Redis -| 1.8.0 +| 1.8 |=== [float] @@ -118,3 +118,21 @@ The spans are named after the schema ` `, for example `GET elastic |1.1 |=== + +[float] +[[supported-cloud-services]] +=== Cloud services + +Automatic instrumentation for the following cloud services + +|=== +| Cloud services | Supported versions | Notes | Supported since agent's version + +| Azure Service Bus +| 3.0.0+ for Microsoft.Azure.ServiceBus, + 7.0.0+ for Azure.Messaging.ServiceBus +| A new transaction is created for received and +receive deferred messages. A new span is created for sent and scheduled messages if there's a current transaction. +| 1.9 + +|=== \ No newline at end of file diff --git a/sample/Elastic.Apm.Azure.ServiceBus.Sample/Elastic.Apm.Azure.ServiceBus.Sample.csproj b/sample/Elastic.Apm.Azure.ServiceBus.Sample/Elastic.Apm.Azure.ServiceBus.Sample.csproj new file mode 100644 index 000000000..a1e58c2ee --- /dev/null +++ b/sample/Elastic.Apm.Azure.ServiceBus.Sample/Elastic.Apm.Azure.ServiceBus.Sample.csproj @@ -0,0 +1,17 @@ + + + + Exe + net5.0 + + + + + + + + + + + + diff --git a/sample/Elastic.Apm.Azure.ServiceBus.Sample/Program.cs b/sample/Elastic.Apm.Azure.ServiceBus.Sample/Program.cs new file mode 100644 index 000000000..c135f30ae --- /dev/null +++ b/sample/Elastic.Apm.Azure.ServiceBus.Sample/Program.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; + +namespace Elastic.Apm.Azure.ServiceBus.Sample +{ + internal class Program + { + private static async Task Main(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("An Azure Service Bus connection string must be passed as the first argument"); + return 1; + } + + Agent.Subscribe(new AzureMessagingServiceBusDiagnosticsSubscriber()); + + var connectionString = args[0]; + var adminClient = new ServiceBusAdministrationClient(connectionString); + var client = new ServiceBusClient(connectionString); + + var queueName = Guid.NewGuid().ToString("D"); + + Console.WriteLine($"Creating queue {queueName}"); + + var response = await adminClient.CreateQueueAsync(queueName).ConfigureAwait(false); + + var sender = client.CreateSender(queueName); + + Console.WriteLine("Sending messages to queue"); + + await Agent.Tracer.CaptureTransaction("Send AzureServiceBus Messages", "messaging", async () => + { + for (var i = 0; i < 10; i++) + await sender.SendMessageAsync(new ServiceBusMessage($"test message {i}")).ConfigureAwait(false); + }); + + var receiver = client.CreateReceiver(queueName); + + Console.WriteLine("Receiving messages from queue"); + + var messages = await receiver.ReceiveMessagesAsync(9) + .ConfigureAwait(false); + + Console.WriteLine("Receiving message from queue"); + + var message = await receiver.ReceiveMessageAsync() + .ConfigureAwait(false); + + Console.WriteLine("Press any key to continue..."); + Console.ReadKey(); + + return 0; + } + } +} diff --git a/src/Elastic.Apm.AspNetCore/DiagnosticListener/AspNetCoreDiagnosticListener.cs b/src/Elastic.Apm.AspNetCore/DiagnosticListener/AspNetCoreDiagnosticListener.cs index 2259b4687..2f745e029 100644 --- a/src/Elastic.Apm.AspNetCore/DiagnosticListener/AspNetCoreDiagnosticListener.cs +++ b/src/Elastic.Apm.AspNetCore/DiagnosticListener/AspNetCoreDiagnosticListener.cs @@ -9,9 +9,9 @@ using Elastic.Apm.Api; using Elastic.Apm.AspNetCore.Extensions; using Elastic.Apm.DiagnosticListeners; -using Elastic.Apm.Helpers; using Elastic.Apm.Logging; using Elastic.Apm.Model; +using Elastic.Apm.Reflection; using Microsoft.AspNetCore.Http; namespace Elastic.Apm.AspNetCore.DiagnosticListener diff --git a/src/Elastic.Apm.Azure.ServiceBus/AzureMessagingServiceBusDiagnosticListener.cs b/src/Elastic.Apm.Azure.ServiceBus/AzureMessagingServiceBusDiagnosticListener.cs new file mode 100644 index 000000000..092b82d37 --- /dev/null +++ b/src/Elastic.Apm.Azure.ServiceBus/AzureMessagingServiceBusDiagnosticListener.cs @@ -0,0 +1,244 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using Elastic.Apm.Api; +using Elastic.Apm.DiagnosticListeners; +using Elastic.Apm.Helpers; +using Elastic.Apm.Logging; + +namespace Elastic.Apm.Azure.ServiceBus +{ + /// + /// Creates spans for diagnostic events from Azure.Messaging.ServiceBus + /// + internal class AzureMessagingServiceBusDiagnosticListener: DiagnosticListenerBase + { + private readonly ApmAgent _realAgent; + private readonly ConcurrentDictionary _processingSegments = new ConcurrentDictionary(); + private readonly Framework _framework; + + public override string Name { get; } = "Azure.Messaging.ServiceBus"; + + public AzureMessagingServiceBusDiagnosticListener(IApmAgent agent) : base(agent) + { + _realAgent = agent as ApmAgent; + _framework = new Framework { Name = ServiceBus.SegmentName }; + } + + protected override void HandleOnNext(KeyValuePair kv) + { + Logger.Trace()?.Log("Called with key: `{DiagnosticEventKey}'", kv.Key); + + if (string.IsNullOrEmpty(kv.Key)) + { + Logger.Trace()?.Log($"Key is {(kv.Key == null ? "null" : "an empty string")} - exiting"); + return; + } + + switch (kv.Key) + { + case "ServiceBusSender.Send.Start": + OnSendStart(kv, "SEND"); + break; + case "ServiceBusSender.Schedule.Start": + OnSendStart(kv, "SCHEDULE"); + break; + case "ServiceBusReceiver.Receive.Start": + OnReceiveStart(kv, "RECEIVE"); + break; + case "ServiceBusReceiver.ReceiveDeferred.Start": + OnReceiveStart(kv, "RECEIVEDEFERRED"); + break; + case "ServiceBusSender.Send.Stop": + case "ServiceBusSender.Schedule.Stop": + case "ServiceBusReceiver.Receive.Stop": + case "ServiceBusReceiver.ReceiveDeferred.Stop": + OnStop(); + break; + case "ServiceBusSender.Send.Exception": + case "ServiceBusSender.Schedule.Exception": + case "ServiceBusReceiver.Receive.Exception": + case "ServiceBusReceiver.ReceiveDeferred.Exception": + OnException(kv); + break; + default: + Logger.Trace()?.Log("`{DiagnosticEventKey}' key is not a traced diagnostic event", kv.Key); + break; + } + } + + private void OnReceiveStart(KeyValuePair kv, string action) + { + if (!(kv.Value is Activity activity)) + { + Logger.Trace()?.Log("Value is not an activity - exiting"); + return; + } + + string queueName = null; + foreach (var tag in activity.Tags) + { + switch (tag.Key) + { + case "message_bus.destination": + queueName = tag.Value; + break; + default: + continue; + } + } + + if (MatchesIgnoreMessageQueues(queueName)) + return; + + var transactionName = queueName is null + ? $"{ServiceBus.SegmentName} {action}" + : $"{ServiceBus.SegmentName} {action} from {queueName}"; + + var transaction = ApmAgent.Tracer.StartTransaction(transactionName, ApiConstants.TypeMessaging); + transaction.Context.Service = new Service(null, null) { Framework = _framework }; + + // transaction creation will create an activity, so use this as the key. + var activityId = Activity.Current.Id; + + if (!_processingSegments.TryAdd(activityId, transaction)) + { + Logger.Error()?.Log( + "Could not add {Action} transaction {TransactionId} for activity {ActivityId} to tracked segments", + action, + transaction.Id, + activityId); + } + } + + private bool MatchesIgnoreMessageQueues(string name) + { + if (name != null && _realAgent != null) + { + var matcher = WildcardMatcher.AnyMatch(_realAgent.ConfigStore.CurrentSnapshot.IgnoreMessageQueues, name); + if (matcher != null) + { + Logger.Debug()?.Log( + "Not tracing message from {QueueName} because it matched IgnoreMessageQueues pattern {Matcher}", + name, + matcher.GetMatcher()); + return true; + } + } + + return false; + } + + private void OnSendStart(KeyValuePair kv, string action) + { + var currentSegment = ApmAgent.GetCurrentExecutionSegment(); + if (currentSegment is null) + { + Logger.Trace()?.Log("No current transaction or span - exiting"); + return; + } + + if (!(kv.Value is Activity activity)) + { + Logger.Trace()?.Log("Value is not an activity - exiting"); + return; + } + + string queueName = null; + string destinationAddress = null; + foreach (var tag in activity.Tags) + { + switch (tag.Key) + { + case "message_bus.destination": + queueName = tag.Value; + break; + case "peer.address": + destinationAddress = tag.Value; + break; + default: + continue; + } + } + + if (MatchesIgnoreMessageQueues(queueName)) + return; + + var spanName = queueName is null + ? $"{ServiceBus.SegmentName} {action}" + : $"{ServiceBus.SegmentName} {action} to {queueName}"; + + var span = currentSegment.StartSpan(spanName, ApiConstants.TypeMessaging, ServiceBus.SubType, action.ToLowerInvariant()); + span.Context.Destination = new Destination + { + Address = destinationAddress, + Service = new Destination.DestinationService + { + Name = ServiceBus.SubType, + Resource = queueName is null ? ServiceBus.SubType : $"{ServiceBus.SubType}/{queueName}", + Type = ApiConstants.TypeMessaging + } + }; + + if (!_processingSegments.TryAdd(activity.Id, span)) + { + Logger.Trace()?.Log( + "Could not add {Action} span {SpanId} for activity {ActivityId} to tracked spans", + action, + span.Id, + activity.Id); + } + } + + private void OnStop() + { + var activity = Activity.Current; + if (activity is null) + { + Logger.Trace()?.Log("Current activity is null - exiting"); + return; + } + + if (!_processingSegments.TryRemove(activity.Id, out var segment)) + { + Logger.Trace()?.Log( + "Could not find segment for activity {ActivityId} in tracked segments", + activity.Id); + return; + } + + segment.Outcome = Outcome.Success; + segment.End(); + } + + private void OnException(KeyValuePair kv) + { + var activity = Activity.Current; + if (activity is null) + { + Logger.Trace()?.Log("Current activity is null - exiting"); + return; + } + + if (!_processingSegments.TryRemove(activity.Id, out var segment)) + { + Logger.Trace()?.Log( + "Could not find segment for activity {ActivityId} in tracked segments", + activity.Id); + return; + } + + if (kv.Value is Exception e) + segment.CaptureException(e); + + segment.Outcome = Outcome.Failure; + segment.End(); + } + } +} diff --git a/src/Elastic.Apm.Azure.ServiceBus/AzureMessagingServiceBusDiagnosticsSubscriber.cs b/src/Elastic.Apm.Azure.ServiceBus/AzureMessagingServiceBusDiagnosticsSubscriber.cs new file mode 100644 index 000000000..446e1cddd --- /dev/null +++ b/src/Elastic.Apm.Azure.ServiceBus/AzureMessagingServiceBusDiagnosticsSubscriber.cs @@ -0,0 +1,34 @@ +// 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.Diagnostics; +using Elastic.Apm.DiagnosticSource; + +namespace Elastic.Apm.Azure.ServiceBus +{ + /// + /// Subscribes to diagnostic source events from Azure.Messaging.ServiceBus + /// + public class AzureMessagingServiceBusDiagnosticsSubscriber : IDiagnosticsSubscriber + { + /// + /// Subscribes diagnostic source events. + /// + public IDisposable Subscribe(IApmAgent agent) + { + var retVal = new CompositeDisposable(); + + var initializer = new DiagnosticInitializer(agent.Logger, new[] { new AzureMessagingServiceBusDiagnosticListener(agent) }); + retVal.Add(initializer); + + retVal.Add(DiagnosticListener + .AllListeners + .Subscribe(initializer)); + + return retVal; + } + } +} diff --git a/src/Elastic.Apm.Azure.ServiceBus/Elastic.Apm.Azure.ServiceBus.csproj b/src/Elastic.Apm.Azure.ServiceBus/Elastic.Apm.Azure.ServiceBus.csproj new file mode 100644 index 000000000..98e2bc686 --- /dev/null +++ b/src/Elastic.Apm.Azure.ServiceBus/Elastic.Apm.Azure.ServiceBus.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0 + Elastic.Apm.Azure.ServiceBus + Elastic.Apm.Azure.ServiceBus + Elastic.Apm.Azure.ServiceBus + Elastic APM for Azure Service Bus. This package contains auto instrumentation for Azure.Messaging.ServiceBus + and Microsoft.Azure.ServiceBus packages. + apm, monitoring, elastic, elasticapm, analytics, azure, service, bus, servicebus + true + + + + + + + + + + + diff --git a/src/Elastic.Apm.Azure.ServiceBus/MicrosoftAzureServiceBusDiagnosticListener.cs b/src/Elastic.Apm.Azure.ServiceBus/MicrosoftAzureServiceBusDiagnosticListener.cs new file mode 100644 index 000000000..b45f980b7 --- /dev/null +++ b/src/Elastic.Apm.Azure.ServiceBus/MicrosoftAzureServiceBusDiagnosticListener.cs @@ -0,0 +1,239 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Elastic.Apm.Api; +using Elastic.Apm.DiagnosticListeners; +using Elastic.Apm.Helpers; +using Elastic.Apm.Logging; +using Elastic.Apm.Reflection; + +namespace Elastic.Apm.Azure.ServiceBus +{ + /// + /// Creates spans for diagnostic events from Microsoft.Azure.ServiceBus + /// + internal class MicrosoftAzureServiceBusDiagnosticListener: DiagnosticListenerBase + { + private readonly ApmAgent _realAgent; + private readonly ConcurrentDictionary _processingSegments = new ConcurrentDictionary(); + private readonly PropertyFetcherCollection _sendProperties = new PropertyFetcherCollection { "Entity", "Endpoint", "Status" }; + private readonly PropertyFetcherCollection _scheduleProperties = new PropertyFetcherCollection { "Entity", "Endpoint", "Status" }; + private readonly PropertyFetcherCollection _receiveProperties = new PropertyFetcherCollection { "Entity", "Endpoint", "Status" }; + private readonly PropertyFetcherCollection _receiveDeferredProperties = new PropertyFetcherCollection { "Entity", "Endpoint", "Status" }; + private readonly PropertyFetcher _exceptionProperty = new PropertyFetcher("Exception"); + private readonly Framework _framework; + + public override string Name { get; } = "Microsoft.Azure.ServiceBus"; + + public MicrosoftAzureServiceBusDiagnosticListener(IApmAgent agent) : base(agent) + { + _realAgent = agent as ApmAgent; + _framework = new Framework { Name = ServiceBus.SegmentName }; + } + + protected override void HandleOnNext(KeyValuePair kv) + { + Logger.Trace()?.Log("Called with key: `{DiagnosticEventKey}'", kv.Key); + + if (string.IsNullOrEmpty(kv.Key)) + { + Logger.Trace()?.Log($"Key is {(kv.Key == null ? "null" : "an empty string")} - exiting"); + return; + } + + switch (kv.Key) + { + case "Microsoft.Azure.ServiceBus.Send.Start": + OnSendStart(kv, "SEND", _sendProperties); + break; + case "Microsoft.Azure.ServiceBus.Send.Stop": + OnStop(kv, _sendProperties); + break; + case "Microsoft.Azure.ServiceBus.Schedule.Start": + OnSendStart(kv, "SCHEDULE", _scheduleProperties); + break; + case "Microsoft.Azure.ServiceBus.Schedule.Stop": + OnStop(kv, _scheduleProperties); + break; + case "Microsoft.Azure.ServiceBus.Receive.Start": + OnReceiveStart(kv, "RECEIVE", _receiveProperties); + break; + case "Microsoft.Azure.ServiceBus.Receive.Stop": + OnStop(kv, _receiveProperties); + break; + case "Microsoft.Azure.ServiceBus.ReceiveDeferred.Start": + OnReceiveStart(kv, "RECEIVEDEFERRED", _receiveDeferredProperties); + break; + case "Microsoft.Azure.ServiceBus.ReceiveDeferred.Stop": + OnStop(kv, _receiveDeferredProperties); + break; + case "Microsoft.Azure.ServiceBus.Exception": + OnException(kv); + break; + default: + Logger.Trace()?.Log("`{DiagnosticEventKey}' key is not a traced diagnostic event", kv.Key); + break; + } + } + + private void OnReceiveStart(KeyValuePair kv, string action, PropertyFetcherCollection cachedProperties) + { + if (kv.Value is null) + { + Logger.Trace()?.Log("Value is null - exiting"); + return; + } + + var queueName = cachedProperties.Fetch(kv.Value,"Entity") as string; + if (MatchesIgnoreMessageQueues(queueName)) + return; + + var transactionName = queueName is null + ? $"{ServiceBus.SegmentName} {action}" + : $"{ServiceBus.SegmentName} {action} from {queueName}"; + + var transaction = ApmAgent.Tracer.StartTransaction(transactionName, ApiConstants.TypeMessaging); + transaction.Context.Service = new Service(null, null) { Framework = _framework }; + + // transaction creation will create an activity, so use this as the key. + var activityId = Activity.Current.Id; + + if (!_processingSegments.TryAdd(activityId, transaction)) + { + Logger.Trace()?.Log( + "Could not add {Action} transaction {TransactionId} for activity {ActivityId} to tracked segments", + action, + transaction.Id, + activityId); + } + } + + private bool MatchesIgnoreMessageQueues(string name) + { + if (name != null && _realAgent != null) + { + var matcher = WildcardMatcher.AnyMatch(_realAgent.ConfigStore.CurrentSnapshot.IgnoreMessageQueues, name); + if (matcher != null) + { + Logger.Debug()?.Log( + "Not tracing message from {QueueName} because it matched IgnoreMessageQueues pattern {Matcher}", + name, + matcher.GetMatcher()); + return true; + } + } + + return false; + } + + private void OnSendStart(KeyValuePair kv, string action, PropertyFetcherCollection cachedProperties) + { + var currentSegment = ApmAgent.GetCurrentExecutionSegment(); + if (currentSegment is null) + { + Logger.Trace()?.Log("No current transaction or span - exiting"); + return; + } + + if (kv.Value is null) + { + Logger.Trace()?.Log("Value is null - exiting"); + return; + } + + var activity = Activity.Current; + var queueName = cachedProperties.Fetch(kv.Value,"Entity") as string; + var destinationAddress = cachedProperties.Fetch(kv.Value, "Endpoint") as Uri; + + if (MatchesIgnoreMessageQueues(queueName)) + return; + + var spanName = queueName is null + ? $"{ServiceBus.SegmentName} {action}" + : $"{ServiceBus.SegmentName} {action} to {queueName}"; + + var span = currentSegment.StartSpan(spanName, ApiConstants.TypeMessaging, ServiceBus.SubType, action.ToLowerInvariant()); + + span.Context.Destination = new Destination + { + Address = destinationAddress?.AbsoluteUri, + Service = new Destination.DestinationService + { + Name = ServiceBus.SubType, + Resource = queueName is null ? ServiceBus.SubType : $"{ServiceBus.SubType}/{queueName}", + Type = ApiConstants.TypeMessaging + } + }; + + if (!_processingSegments.TryAdd(activity.Id, span)) + { + Logger.Trace()?.Log( + "Could not add {Action} span {SpanId} for activity {ActivityId} to tracked segments", + action, + span.Id, + activity.Id); + } + } + + private void OnStop(KeyValuePair kv, PropertyFetcherCollection cachedProperties) + { + var activity = Activity.Current; + if (activity is null) + { + Logger.Trace()?.Log("Current activity is null - exiting"); + return; + } + + if (!_processingSegments.TryRemove(activity.Id, out var segment)) + { + Logger.Trace()?.Log( + "Could not find segment for activity {ActivityId} in tracked segments", + activity.Id); + return; + } + + var status = cachedProperties.Fetch(kv.Value, "Status") as TaskStatus?; + var outcome = status switch + { + TaskStatus.RanToCompletion => Outcome.Success, + TaskStatus.Canceled => Outcome.Failure, + TaskStatus.Faulted => Outcome.Failure, + _ => Outcome.Unknown + }; + + segment.Outcome = outcome; + segment.End(); + } + + private void OnException(KeyValuePair kv) + { + var activity = Activity.Current; + if (activity is null) + { + Logger.Trace()?.Log("Current activity is null - exiting"); + return; + } + + if (!_processingSegments.TryRemove(activity.Id, out var segment)) + { + Logger.Trace()?.Log( + "Could not find segment for activity {ActivityId} in tracked segments", + activity.Id); + return; + } + + if (_exceptionProperty.Fetch(kv.Value) is Exception exception) + segment.CaptureException(exception); + + segment.Outcome = Outcome.Failure; + segment.End(); + } + } +} diff --git a/src/Elastic.Apm.Azure.ServiceBus/MicrosoftAzureServiceBusDiagnosticsSubscriber.cs b/src/Elastic.Apm.Azure.ServiceBus/MicrosoftAzureServiceBusDiagnosticsSubscriber.cs new file mode 100644 index 000000000..e2e012abc --- /dev/null +++ b/src/Elastic.Apm.Azure.ServiceBus/MicrosoftAzureServiceBusDiagnosticsSubscriber.cs @@ -0,0 +1,34 @@ +// 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.Diagnostics; +using Elastic.Apm.DiagnosticSource; + +namespace Elastic.Apm.Azure.ServiceBus +{ + /// + /// Subscribes to diagnostic source events from Microsoft.Azure.ServiceBus + /// + public class MicrosoftAzureServiceBusDiagnosticsSubscriber : IDiagnosticsSubscriber + { + /// + /// Subscribes diagnostic source events. + /// + public IDisposable Subscribe(IApmAgent agent) + { + var retVal = new CompositeDisposable(); + + var initializer = new DiagnosticInitializer(agent.Logger, new[] { new MicrosoftAzureServiceBusDiagnosticListener(agent) }); + retVal.Add(initializer); + + retVal.Add(DiagnosticListener + .AllListeners + .Subscribe(initializer)); + + return retVal; + } + } +} diff --git a/src/Elastic.Apm.Azure.ServiceBus/ServiceBus.cs b/src/Elastic.Apm.Azure.ServiceBus/ServiceBus.cs new file mode 100644 index 000000000..45c9a80cc --- /dev/null +++ b/src/Elastic.Apm.Azure.ServiceBus/ServiceBus.cs @@ -0,0 +1,13 @@ +// 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 + +namespace Elastic.Apm.Azure.ServiceBus +{ + internal static class ServiceBus + { + public const string SegmentName = "AzureServiceBus"; + public const string SubType = "azureservicebus"; + } +} diff --git a/src/Elastic.Apm.Elasticsearch/ElasticsearchDiagnosticsListenerBase.cs b/src/Elastic.Apm.Elasticsearch/ElasticsearchDiagnosticsListenerBase.cs index 952384eea..ce4d94570 100644 --- a/src/Elastic.Apm.Elasticsearch/ElasticsearchDiagnosticsListenerBase.cs +++ b/src/Elastic.Apm.Elasticsearch/ElasticsearchDiagnosticsListenerBase.cs @@ -50,7 +50,7 @@ internal bool TryStartElasticsearchSpan(string name, out Span span, Uri instance if (transaction == null) return false; - span = (Span)ExecutionSegmentCommon.GetCurrentExecutionSegment(ApmAgent) + span = (Span)ApmAgent.GetCurrentExecutionSegment() .StartSpan( name, ApiConstants.TypeDb, diff --git a/src/Elastic.Apm.NetCoreAll/ApmMiddlewareExtension.cs b/src/Elastic.Apm.NetCoreAll/ApmMiddlewareExtension.cs index 6737493d5..04576f747 100644 --- a/src/Elastic.Apm.NetCoreAll/ApmMiddlewareExtension.cs +++ b/src/Elastic.Apm.NetCoreAll/ApmMiddlewareExtension.cs @@ -2,6 +2,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 Elastic.Apm.Azure.ServiceBus; using Elastic.Apm.DiagnosticSource; using Elastic.Apm.Elasticsearch; using Elastic.Apm.EntityFrameworkCore; @@ -15,8 +16,14 @@ namespace Elastic.Apm.NetCoreAll public static class ApmMiddlewareExtension { /// - /// Adds the Elastic APM Middleware to the ASP.NET Core pipeline and enables , - /// , and . + /// Adds the Elastic APM Middleware to the ASP.NET Core pipeline and enables + /// , + /// , + /// , + /// . + /// , + /// , + /// and /// This method turns on ASP.NET Core monitoring with every other related monitoring components, for example the agent /// will also automatically trace outgoing HTTP requests and database statements. /// @@ -31,7 +38,13 @@ public static IApplicationBuilder UseAllElasticApm( this IApplicationBuilder builder, IConfiguration configuration = null ) => AspNetCore.ApmMiddlewareExtension - .UseElasticApm(builder, configuration, new HttpDiagnosticsSubscriber(), new EfCoreDiagnosticsSubscriber(), - new SqlClientDiagnosticSubscriber(), new ElasticsearchDiagnosticsSubscriber(), new GrpcClientDiagnosticSubscriber()); + .UseElasticApm(builder, configuration, + new HttpDiagnosticsSubscriber(), + new EfCoreDiagnosticsSubscriber(), + new SqlClientDiagnosticSubscriber(), + new ElasticsearchDiagnosticsSubscriber(), + new GrpcClientDiagnosticSubscriber(), + new AzureMessagingServiceBusDiagnosticsSubscriber(), + new MicrosoftAzureServiceBusDiagnosticsSubscriber()); } } diff --git a/src/Elastic.Apm.NetCoreAll/Elastic.Apm.NetCoreAll.csproj b/src/Elastic.Apm.NetCoreAll/Elastic.Apm.NetCoreAll.csproj index 26934ad5f..968c76b99 100644 --- a/src/Elastic.Apm.NetCoreAll/Elastic.Apm.NetCoreAll.csproj +++ b/src/Elastic.Apm.NetCoreAll/Elastic.Apm.NetCoreAll.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Elastic.Apm.NetCoreAll/HostBuilderExtensions.cs b/src/Elastic.Apm.NetCoreAll/HostBuilderExtensions.cs index e732be73e..511442be7 100644 --- a/src/Elastic.Apm.NetCoreAll/HostBuilderExtensions.cs +++ b/src/Elastic.Apm.NetCoreAll/HostBuilderExtensions.cs @@ -4,6 +4,7 @@ // See the LICENSE file in the project root for more information using Elastic.Apm.AspNetCore.DiagnosticListener; +using Elastic.Apm.Azure.ServiceBus; using Elastic.Apm.DiagnosticSource; using Elastic.Apm.Elasticsearch; using Elastic.Apm.EntityFrameworkCore; @@ -17,15 +18,25 @@ namespace Elastic.Apm.NetCoreAll public static class HostBuilderExtensions { /// - /// Register Elastic APM .NET Agent with components in the container and enables , - /// , and . + /// Register Elastic APM .NET Agent with components in the container and enables + /// , + /// , + /// , + /// , + /// . + /// , + /// , + /// and /// /// Builder. - public static IHostBuilder UseAllElasticApm(this IHostBuilder builder) => builder.UseElasticApm(new HttpDiagnosticsSubscriber(), + public static IHostBuilder UseAllElasticApm(this IHostBuilder builder) => builder.UseElasticApm( + new HttpDiagnosticsSubscriber(), new AspNetCoreDiagnosticSubscriber(), new EfCoreDiagnosticsSubscriber(), new SqlClientDiagnosticSubscriber(), new ElasticsearchDiagnosticsSubscriber(), - new GrpcClientDiagnosticSubscriber()); + new GrpcClientDiagnosticSubscriber(), + new AzureMessagingServiceBusDiagnosticsSubscriber(), + new MicrosoftAzureServiceBusDiagnosticsSubscriber()); } } diff --git a/src/Elastic.Apm.SqlClient/SqlClientDiagnosticListener.cs b/src/Elastic.Apm.SqlClient/SqlClientDiagnosticListener.cs index 756295aae..7bd28cc2f 100644 --- a/src/Elastic.Apm.SqlClient/SqlClientDiagnosticListener.cs +++ b/src/Elastic.Apm.SqlClient/SqlClientDiagnosticListener.cs @@ -12,6 +12,7 @@ using Elastic.Apm.Helpers; using Elastic.Apm.Logging; using Elastic.Apm.Model; +using Elastic.Apm.Reflection; namespace Elastic.Apm.SqlClient { diff --git a/src/Elastic.Apm.StackExchange.Redis/ElasticApmProfiler.cs b/src/Elastic.Apm.StackExchange.Redis/ElasticApmProfiler.cs index 5627584a1..973f00593 100644 --- a/src/Elastic.Apm.StackExchange.Redis/ElasticApmProfiler.cs +++ b/src/Elastic.Apm.StackExchange.Redis/ElasticApmProfiler.cs @@ -45,7 +45,7 @@ public ProfilingSession GetProfilingSession() if (!Agent.Config.Enabled || !Agent.Config.Recording) return null; - var executionSegment = ExecutionSegmentCommon.GetCurrentExecutionSegment(_agent.Value); + var executionSegment = _agent.Value.GetCurrentExecutionSegment(); var realSpan = executionSegment as Span; Transaction realTransaction = null; diff --git a/src/Elastic.Apm/Api/ApiConstants.cs b/src/Elastic.Apm/Api/ApiConstants.cs index 0135c1396..e6984cdea 100644 --- a/src/Elastic.Apm/Api/ApiConstants.cs +++ b/src/Elastic.Apm/Api/ApiConstants.cs @@ -23,5 +23,6 @@ public struct ApiConstants public const string TypeDb = "db"; public const string TypeExternal = "external"; + public const string TypeMessaging = "messaging"; } } diff --git a/src/Elastic.Apm/ApmAgentExtensions.cs b/src/Elastic.Apm/ApmAgentExtensions.cs index 7bc13a171..c2aff2eb7 100644 --- a/src/Elastic.Apm/ApmAgentExtensions.cs +++ b/src/Elastic.Apm/ApmAgentExtensions.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Elastic.Apm.Api; using Elastic.Apm.DiagnosticSource; namespace Elastic.Apm @@ -40,6 +41,10 @@ public static IDisposable Subscribe(this IApmAgent agent, params IDiagnosticsSub return disposable; } + + internal static IExecutionSegment GetCurrentExecutionSegment(this IApmAgent agent) => + agent.Tracer.CurrentSpan ?? (IExecutionSegment)agent.Tracer.CurrentTransaction; + } /// diff --git a/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigFetcher.cs b/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigFetcher.cs index 438088cc7..f5fec2a99 100644 --- a/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigFetcher.cs +++ b/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigFetcher.cs @@ -266,6 +266,8 @@ internal WrappingConfigSnapshot(IConfigSnapshot wrapped, CentralConfigReader cen public string HostName => _wrapped.HostName; + public IReadOnlyList IgnoreMessageQueues => _centralConfig.IgnoreMessageQueues ?? _wrapped.IgnoreMessageQueues; + public LogLevel LogLevel => _centralConfig.LogLevel ?? _wrapped.LogLevel; public int MaxBatchEventCount => _wrapped.MaxBatchEventCount; diff --git a/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigReader.cs b/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigReader.cs index cff86567a..28464e1a4 100644 --- a/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigReader.cs +++ b/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigReader.cs @@ -35,6 +35,8 @@ public CentralConfigReader(IApmLogger logger, CentralConfigResponseParser.Centra internal string ETag { get; } + internal IReadOnlyList IgnoreMessageQueues { get; private set; } + internal LogLevel? LogLevel { get; private set; } internal bool? Recording { get; private set; } @@ -71,6 +73,8 @@ private void UpdateConfigurationValues() GetConfigurationValue(CentralConfigResponseParser.CentralConfigPayload.SanitizeFieldNames, ParseSanitizeFieldNames); TransactionIgnoreUrls = GetConfigurationValue(CentralConfigResponseParser.CentralConfigPayload.TransactionIgnoreUrls, ParseTransactionIgnoreUrls); + IgnoreMessageQueues = + GetConfigurationValue(CentralConfigResponseParser.CentralConfigPayload.IgnoreMessageQueues, ParseIgnoreMessageQueuesImpl); } private ConfigurationKeyValue BuildKv(string key, string value) => diff --git a/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigResponseParser.cs b/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigResponseParser.cs index b304f6a4b..1a86c6451 100644 --- a/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigResponseParser.cs +++ b/src/Elastic.Apm/BackendComm/CentralConfig/CentralConfigResponseParser.cs @@ -136,8 +136,8 @@ internal class CentralConfigPayload { internal const string CaptureBodyContentTypesKey = "capture_body_content_types"; internal const string CaptureBodyKey = "capture_body"; - internal const string CaptureHeadersKey = "capture_headers"; + internal const string IgnoreMessageQueues = "ignore_message_queues"; internal const string LogLevelKey = "log_level"; internal const string Recording = "recording"; internal const string SanitizeFieldNames = "sanitize_field_names"; @@ -151,12 +151,16 @@ internal class CentralConfigPayload { CaptureBodyKey, CaptureBodyContentTypesKey, - TransactionMaxSpansKey, - TransactionSampleRateKey, CaptureHeadersKey, + IgnoreMessageQueues, LogLevelKey, + Recording, + SanitizeFieldNames, SpanFramesMinDurationKey, - StackTraceLimitKey + StackTraceLimitKey, + TransactionIgnoreUrls, + TransactionMaxSpansKey, + TransactionSampleRateKey, }; private readonly IDictionary _keyValues; diff --git a/src/Elastic.Apm/Config/AbstractConfigurationReader.cs b/src/Elastic.Apm/Config/AbstractConfigurationReader.cs index be8388a4d..cac132ac1 100644 --- a/src/Elastic.Apm/Config/AbstractConfigurationReader.cs +++ b/src/Elastic.Apm/Config/AbstractConfigurationReader.cs @@ -27,6 +27,9 @@ public abstract class AbstractConfigurationReader private readonly LazyContextualInit> _cachedWildcardMatchersDisableMetrics = new LazyContextualInit>(); + private readonly LazyContextualInit> _cachedWildcardMatchersIgnoreMessageQueues = + new LazyContextualInit>(); + private readonly LazyContextualInit> _cachedWildcardMatchersSanitizeFieldNames = new LazyContextualInit>(); @@ -108,7 +111,6 @@ protected IReadOnlyList ParseDisableMetrics(ConfigurationKeyVal _cachedWildcardMatchersDisableMetrics.IfNotInited?.InitOrGet(() => ParseDisableMetricsImpl(kv)) ?? _cachedWildcardMatchersDisableMetrics.Value; - private IReadOnlyList ParseDisableMetricsImpl(ConfigurationKeyValue kv) { if (kv?.Value == null) return DefaultValues.DisableMetrics; @@ -129,6 +131,31 @@ private IReadOnlyList ParseDisableMetricsImpl(ConfigurationKeyV } } + protected IReadOnlyList ParseIgnoreMessageQueues(ConfigurationKeyValue kv) => + _cachedWildcardMatchersIgnoreMessageQueues.IfNotInited?.InitOrGet(() => ParseIgnoreMessageQueuesImpl(kv)) + ?? _cachedWildcardMatchersIgnoreMessageQueues.Value; + + internal IReadOnlyList ParseIgnoreMessageQueuesImpl(ConfigurationKeyValue kv) + { + if (kv?.Value == null || string.IsNullOrWhiteSpace(kv.Value)) + return DefaultValues.IgnoreMessageQueues; + + try + { + _logger?.Trace()?.Log("Try parsing IgnoreMessageQueues, values: {IgnoreMessageQueues}", kv.Value); + var ignoreMessageQueues = kv.Value.Split(',').Where(n => !string.IsNullOrWhiteSpace(n)).ToList(); + + var retVal = new List(ignoreMessageQueues.Count); + foreach (var item in ignoreMessageQueues) retVal.Add(WildcardMatcher.ValueOf(item.Trim())); + return retVal; + } + catch (Exception e) + { + _logger?.Error()?.LogException(e, "Failed parsing IgnoreMessageQueues, values in the config: {IgnoreMessageQueues}", kv.Value); + return DefaultValues.IgnoreMessageQueues; + } + } + protected string ParseSecretToken(ConfigurationKeyValue kv) { if (kv == null || string.IsNullOrEmpty(kv.Value)) return null; diff --git a/src/Elastic.Apm/Config/AbstractConfigurationWithEnvFallbackReader.cs b/src/Elastic.Apm/Config/AbstractConfigurationWithEnvFallbackReader.cs index e16ac94df..f61df82b8 100644 --- a/src/Elastic.Apm/Config/AbstractConfigurationWithEnvFallbackReader.cs +++ b/src/Elastic.Apm/Config/AbstractConfigurationWithEnvFallbackReader.cs @@ -69,6 +69,9 @@ internal AbstractConfigurationWithEnvFallbackReader(IApmLogger logger, string de public virtual string HostName => ParseHostName(Read(KeyNames.HostName, EnvVarNames.HostName)); + public IReadOnlyList IgnoreMessageQueues => + ParseIgnoreMessageQueues(Read(KeyNames.IgnoreMessageQueues, EnvVarNames.IgnoreMessageQueues)); + public virtual LogLevel LogLevel => ParseLogLevel(Read(KeyNames.LogLevel, EnvVarNames.LogLevel)); public virtual int MaxBatchEventCount => diff --git a/src/Elastic.Apm/Config/ConfigConsts.cs b/src/Elastic.Apm/Config/ConfigConsts.cs index 3d1c78f98..438a18a9a 100644 --- a/src/Elastic.Apm/Config/ConfigConsts.cs +++ b/src/Elastic.Apm/Config/ConfigConsts.cs @@ -55,6 +55,8 @@ public static class DefaultValues public static List DisableMetrics = new List(); + public static List IgnoreMessageQueues = new List(); + public static List SanitizeFieldNames; public static List TransactionIgnoreUrls; @@ -122,6 +124,7 @@ public static class EnvVarNames public const string FullFrameworkConfigurationReaderType = Prefix + "FULL_FRAMEWORK_CONFIGURATION_READER_TYPE"; public const string GlobalLabels = Prefix + "GLOBAL_LABELS"; public const string HostName = Prefix + "HOSTNAME"; + public const string IgnoreMessageQueues = Prefix + "IGNORE_MESSAGE_QUEUES"; public const string LogLevel = Prefix + "LOG_LEVEL"; public const string MaxBatchEventCount = Prefix + "MAX_BATCH_EVENT_COUNT"; public const string MaxQueueEventCount = Prefix + "MAX_QUEUE_EVENT_COUNT"; @@ -163,6 +166,7 @@ public static class KeyNames public const string FullFrameworkConfigurationReaderType = Prefix + nameof(FullFrameworkConfigurationReaderType); public const string GlobalLabels = Prefix + nameof(GlobalLabels); public const string HostName = Prefix + nameof(HostName); + public const string IgnoreMessageQueues = Prefix + nameof(IgnoreMessageQueues); public const string LogLevel = Prefix + nameof(LogLevel); public const string MaxBatchEventCount = Prefix + nameof(MaxBatchEventCount); public const string MaxQueueEventCount = Prefix + nameof(MaxQueueEventCount); diff --git a/src/Elastic.Apm/Config/ConfigSnapshotFromReader.cs b/src/Elastic.Apm/Config/ConfigSnapshotFromReader.cs index e154acc5d..acc759155 100644 --- a/src/Elastic.Apm/Config/ConfigSnapshotFromReader.cs +++ b/src/Elastic.Apm/Config/ConfigSnapshotFromReader.cs @@ -35,6 +35,7 @@ internal ConfigSnapshotFromReader(IConfigurationReader content, string dbgDescri public TimeSpan FlushInterval => _content.FlushInterval; public IReadOnlyDictionary GlobalLabels => _content.GlobalLabels; public string HostName => _content.HostName; + public IReadOnlyList IgnoreMessageQueues => _content.IgnoreMessageQueues; public LogLevel LogLevel => _content.LogLevel; public int MaxBatchEventCount => _content.MaxBatchEventCount; public int MaxQueueEventCount => _content.MaxQueueEventCount; diff --git a/src/Elastic.Apm/Config/EnvironmentConfigurationReader.cs b/src/Elastic.Apm/Config/EnvironmentConfigurationReader.cs index 2bd6cc5a8..7cbf3a6e0 100644 --- a/src/Elastic.Apm/Config/EnvironmentConfigurationReader.cs +++ b/src/Elastic.Apm/Config/EnvironmentConfigurationReader.cs @@ -55,6 +55,8 @@ public EnvironmentConfigurationReader(IApmLogger logger = null) : base(logger, T public string HostName => ParseHostName(Read(ConfigConsts.EnvVarNames.HostName)); + public IReadOnlyList IgnoreMessageQueues => ParseIgnoreMessageQueues(Read(ConfigConsts.EnvVarNames.IgnoreMessageQueues)); + public LogLevel LogLevel => ParseLogLevel(Read(ConfigConsts.EnvVarNames.LogLevel)); public int MaxBatchEventCount => ParseMaxBatchEventCount(Read(ConfigConsts.EnvVarNames.MaxBatchEventCount)); diff --git a/src/Elastic.Apm/Config/IConfigurationReader.cs b/src/Elastic.Apm/Config/IConfigurationReader.cs index cc05f654d..3bc4751f4 100644 --- a/src/Elastic.Apm/Config/IConfigurationReader.cs +++ b/src/Elastic.Apm/Config/IConfigurationReader.cs @@ -120,6 +120,13 @@ public interface IConfigurationReader /// string HostName { get; } + /// + /// Disables the tracing of messages from certain queues, topics exchanges. + /// If the name of a queue, topic or exchange matches any of the wildcard expressions, it will + /// not be traced + /// + IReadOnlyList IgnoreMessageQueues { get; } + /// /// The logging level for the agent. /// diff --git a/src/Elastic.Apm/Elastic.Apm.csproj b/src/Elastic.Apm/Elastic.Apm.csproj index fe0f038b7..cf704d881 100644 --- a/src/Elastic.Apm/Elastic.Apm.csproj +++ b/src/Elastic.Apm/Elastic.Apm.csproj @@ -44,6 +44,8 @@ + + diff --git a/src/Elastic.Apm/Model/ExecutionSegmentCommon.cs b/src/Elastic.Apm/Model/ExecutionSegmentCommon.cs index ca7e40a9f..6cf1f2634 100644 --- a/src/Elastic.Apm/Model/ExecutionSegmentCommon.cs +++ b/src/Elastic.Apm/Model/ExecutionSegmentCommon.cs @@ -258,14 +258,11 @@ public static void CaptureError( }); } - internal static IExecutionSegment GetCurrentExecutionSegment(IApmAgent agent) => - agent.Tracer.CurrentSpan ?? (IExecutionSegment)agent.Tracer.CurrentTransaction; - internal static ISpan StartSpanOnCurrentExecutionSegment(IApmAgent agent, string spanName, string spanType, string subType = null, InstrumentationFlag instrumentationFlag = InstrumentationFlag.None, bool captureStackTraceOnStart = false ) { - var currentExecutionSegment = GetCurrentExecutionSegment(agent); + var currentExecutionSegment = agent.GetCurrentExecutionSegment(); if (currentExecutionSegment == null) return null; diff --git a/src/Elastic.Apm/Helpers/CascadePropertyFetcher.cs b/src/Elastic.Apm/Reflection/CascadePropertyFetcher.cs similarity index 94% rename from src/Elastic.Apm/Helpers/CascadePropertyFetcher.cs rename to src/Elastic.Apm/Reflection/CascadePropertyFetcher.cs index 65b51295e..09470bcd6 100644 --- a/src/Elastic.Apm/Helpers/CascadePropertyFetcher.cs +++ b/src/Elastic.Apm/Reflection/CascadePropertyFetcher.cs @@ -2,7 +2,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 -namespace Elastic.Apm.Helpers +namespace Elastic.Apm.Reflection { internal class CascadePropertyFetcher : PropertyFetcher { diff --git a/src/Elastic.Apm/Reflection/ExpressionBuilder.cs b/src/Elastic.Apm/Reflection/ExpressionBuilder.cs index c4c8ba4c5..03c9efa45 100644 --- a/src/Elastic.Apm/Reflection/ExpressionBuilder.cs +++ b/src/Elastic.Apm/Reflection/ExpressionBuilder.cs @@ -34,5 +34,18 @@ public static Func BuildPropertyGetter(Type type, PropertyInfo p var returnCastExpression = Expression.Convert(memberExpression, typeof(object)); return Expression.Lambda>(returnCastExpression, parameterExpression).Compile(); } + + /// + /// Builds a delegate to get a property from an object. is cast to , + /// with the returned property cast to . + /// + public static Func BuildPropertyGetter(Type type, string propertyName) + { + var parameterExpression = Expression.Parameter(typeof(object), "value"); + var parameterCastExpression = Expression.Convert(parameterExpression, type); + var memberExpression = Expression.Property(parameterCastExpression, propertyName); + var returnCastExpression = Expression.Convert(memberExpression, typeof(object)); + return Expression.Lambda>(returnCastExpression, parameterExpression).Compile(); + } } } diff --git a/src/Elastic.Apm/Helpers/PropertyFetcher.cs b/src/Elastic.Apm/Reflection/PropertyFetcher.cs similarity index 91% rename from src/Elastic.Apm/Helpers/PropertyFetcher.cs rename to src/Elastic.Apm/Reflection/PropertyFetcher.cs index 39524f792..71f2c4bba 100644 --- a/src/Elastic.Apm/Helpers/PropertyFetcher.cs +++ b/src/Elastic.Apm/Reflection/PropertyFetcher.cs @@ -6,11 +6,11 @@ using System.Linq; using System.Reflection; -namespace Elastic.Apm.Helpers +namespace Elastic.Apm.Reflection { internal class PropertyFetcher { - private readonly string _propertyName; + public string PropertyName { get; } private PropertyFetch _innerFetcher; public PropertyFetcher(string propertyName) @@ -18,7 +18,7 @@ public PropertyFetcher(string propertyName) if (string.IsNullOrWhiteSpace(propertyName)) throw new ArgumentException("The value must be non-empty, non-null or non-whitespace", nameof(propertyName)); - _propertyName = propertyName; + PropertyName = propertyName; } public virtual object Fetch(object obj) @@ -26,10 +26,10 @@ public virtual object Fetch(object obj) if (_innerFetcher == null) { var type = obj.GetType().GetTypeInfo(); - var property = type.DeclaredProperties.FirstOrDefault(p => string.Equals(p.Name, _propertyName, StringComparison.OrdinalIgnoreCase)); + var property = type.DeclaredProperties.FirstOrDefault(p => string.Equals(p.Name, PropertyName, StringComparison.OrdinalIgnoreCase)); if (property == null) { - property = type.GetProperty(_propertyName); + property = type.GetProperty(PropertyName); } _innerFetcher = PropertyFetch.FetcherForProperty(property); diff --git a/src/Elastic.Apm/Reflection/PropertyFetcherCollection.cs b/src/Elastic.Apm/Reflection/PropertyFetcherCollection.cs new file mode 100644 index 000000000..ab2a78f81 --- /dev/null +++ b/src/Elastic.Apm/Reflection/PropertyFetcherCollection.cs @@ -0,0 +1,38 @@ +// 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.Collections.Generic; + +namespace Elastic.Apm.Reflection +{ + /// + /// A collection of property fetchers, used to retrieve property values + /// from objects at runtime. + /// + internal class PropertyFetcherCollection : IEnumerable + { + private readonly Dictionary _propertyFetchers; + + public PropertyFetcherCollection() => + _propertyFetchers = new Dictionary(); + + public PropertyFetcherCollection(int capacity) => + _propertyFetchers = new Dictionary(capacity); + + public void Add(PropertyFetcher propertyFetcher) => + _propertyFetchers.Add(propertyFetcher.PropertyName, propertyFetcher); + + public void Add(string propertyName) => + _propertyFetchers.Add(propertyName, new PropertyFetcher(propertyName)); + + public object Fetch(object obj, string propertyName) => + _propertyFetchers.TryGetValue(propertyName, out var fetcher) ? fetcher.Fetch(obj) : null; + + public IEnumerator GetEnumerator() => _propertyFetchers.Values.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/AzureCredentials.cs b/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/AzureCredentials.cs new file mode 100644 index 000000000..1b33e2a4c --- /dev/null +++ b/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/AzureCredentials.cs @@ -0,0 +1,134 @@ +// 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.Runtime.InteropServices; +using System.Threading; +using Elastic.Apm.Tests.Utilities; +using Newtonsoft.Json; +using ProcNet; + +namespace Elastic.Apm.Azure.ServiceBus.Tests.Azure +{ + public class Unauthenticated : AzureCredentials + { + } + + public class AzureUserAccount : AzureCredentials + { + } + + public class ServicePrincipal : AzureCredentials + { + [JsonConstructor] + private ServicePrincipal() { } + + [JsonProperty("clientId")] + public string ClientId { get; private set; } + + [JsonProperty("clientSecret")] + public string ClientSecret { get; private set; } + + [JsonProperty("tenantId")] + public string TenantId { get; private set; } + + [JsonProperty("subscriptionId")] + public string SubscriptionId { get; private set; } + + public ServicePrincipal(string clientId, string clientSecret, string tenantId, string subscriptionId) + { + ClientId = clientId; + ClientSecret = clientSecret; + TenantId = tenantId; + SubscriptionId = subscriptionId; + } + public override void AddToArguments(StartArguments startArguments) + { + startArguments.Environment ??= new Dictionary(); + startArguments.Environment[ARM_CLIENT_ID] = ClientId; + startArguments.Environment[ARM_CLIENT_SECRET] = ClientSecret; + startArguments.Environment[ARM_SUBSCRIPTION_ID] = SubscriptionId; + startArguments.Environment[ARM_TENANT_ID] = TenantId; + } + } + + public abstract class AzureCredentials + { + // ReSharper disable InconsistentNaming + protected const string ARM_CLIENT_ID = nameof(ARM_CLIENT_ID); + protected const string ARM_CLIENT_SECRET = nameof(ARM_CLIENT_SECRET); + protected const string ARM_TENANT_ID = nameof(ARM_TENANT_ID); + protected const string ARM_SUBSCRIPTION_ID = nameof(ARM_SUBSCRIPTION_ID); + // ReSharper restore InconsistentNaming + + private static readonly Lazy _lazyCredentials = + new Lazy(LoadCredentials, LazyThreadSafetyMode.ExecutionAndPublication); + + private static AzureCredentials LoadCredentials() + { + var runningInCi = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BUILD_ID")); + if (runningInCi) + { + var credentialsFile = Path.Combine(SolutionPaths.Root, ".credentials.json"); + if (!File.Exists(credentialsFile)) + return new Unauthenticated(); + + try + { + using var fileStream = new FileStream(credentialsFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var streamReader = new StreamReader(fileStream); + using var jsonTextReader = new JsonTextReader(streamReader); + var serializer = new JsonSerializer(); + return serializer.Deserialize(jsonTextReader); + } + catch (Exception e) + { + Console.WriteLine(e); + return new Unauthenticated(); + } + } + + return LoggedIntoAccountWithAzureCli() + ? new AzureUserAccount() + : new Unauthenticated(); + } + + /// + /// Checks that Azure CLI is installed and in the PATH, and is logged into an account + /// + /// true if logged in + private static bool LoggedIntoAccountWithAzureCli() + { + try + { + // run azure CLI using cmd on Windows so that %~dp0 in az.cmd expands to + // the path containing the cmd file. + var binary = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "cmd" + : "az"; + var args = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new[] { "/c", "az", "account", "show" } + : new[] { "account", "show" }; + + var result = Proc.Start(new StartArguments(binary, args)); + return result.Completed && result.ExitCode == 0; + } + catch (Exception e) + { + Console.WriteLine(e); + return false; + } + } + + /// + /// A set of Azure credentials obtained from environment variables or a .credentials.json configuration file + /// + public static AzureCredentials Instance => _lazyCredentials.Value; + + public virtual void AddToArguments(StartArguments startArguments) { } + } +} diff --git a/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/AzureCredentialsFactAttribute.cs b/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/AzureCredentialsFactAttribute.cs new file mode 100644 index 000000000..e486568ee --- /dev/null +++ b/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/AzureCredentialsFactAttribute.cs @@ -0,0 +1,21 @@ +// 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 Xunit; + +namespace Elastic.Apm.Azure.ServiceBus.Tests.Azure +{ + /// + /// Attribute applied to a test that should be run by the test runner if Azure credentials are available + /// + public class AzureCredentialsFactAttribute : FactAttribute + { + public AzureCredentialsFactAttribute() + { + if (AzureCredentials.Instance is Unauthenticated) + Skip = "Azure credentials not available. If running locally, run `az login` to login"; + } + } +} diff --git a/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/AzureServiceBusTestEnvironment.cs b/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/AzureServiceBusTestEnvironment.cs new file mode 100644 index 000000000..efaf37fbc --- /dev/null +++ b/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/AzureServiceBusTestEnvironment.cs @@ -0,0 +1,67 @@ +// 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 Azure.Messaging.ServiceBus; +using Elastic.Apm.Azure.ServiceBus.Tests.Terraform; +using Elastic.Apm.Tests.Utilities; +using Xunit; +using Xunit.Abstractions; + +namespace Elastic.Apm.Azure.ServiceBus.Tests.Azure +{ + [CollectionDefinition("AzureServiceBus")] + public class AzureServiceBusTestEnvironmentCollection : ICollectionFixture + { + + } + + /// + /// A test environment for Azure Service Bus that deploys and configures an Azure Service Bus namespace + /// in a given region and location + /// + /// + /// Resource name rules + /// https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules + /// + public class AzureServiceBusTestEnvironment : IDisposable + { + private readonly TerraformResources _terraform; + private readonly Dictionary _variables; + + public AzureServiceBusTestEnvironment(IMessageSink messageSink) + { + var solutionRoot = SolutionPaths.Root; + var terraformResourceDirectory = Path.Combine(solutionRoot, "build", "terraform", "azure", "service_bus"); + var credentials = AzureCredentials.Instance; + + _terraform = new TerraformResources(terraformResourceDirectory, credentials, messageSink); + + var machineName = Environment.MachineName.ToLowerInvariant(); + if (machineName.Length > 66) + machineName = machineName.Substring(0, 66); + + _variables = new Dictionary + { + ["resource_group"] = $"dotnet-{machineName}-service-bus-test", + ["servicebus_namespace"] = "dotnet-" + Guid.NewGuid() + }; + + _terraform.Init(); + _terraform.Apply(_variables); + + ServiceBusConnectionString = _terraform.Output("connection_string"); + ServiceBusConnectionStringProperties = ServiceBusConnectionStringProperties.Parse(ServiceBusConnectionString); + } + + public string ServiceBusConnectionString { get; } + + public ServiceBusConnectionStringProperties ServiceBusConnectionStringProperties { get; } + + public void Dispose() => _terraform.Destroy(_variables); + } +} diff --git a/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/QueueScope.cs b/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/QueueScope.cs new file mode 100644 index 000000000..dda9a97c0 --- /dev/null +++ b/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/QueueScope.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.Threading.Tasks; +using Azure.Messaging.ServiceBus.Administration; + +namespace Elastic.Apm.Azure.ServiceBus.Tests.Azure +{ + public class QueueScope : IAsyncDisposable + { + public string QueueName { get; } + private readonly QueueProperties _properties; + private readonly ServiceBusAdministrationClient _adminClient; + + private QueueScope(ServiceBusAdministrationClient adminClient, string queueName, QueueProperties properties) + { + _adminClient = adminClient; + QueueName = queueName; + _properties = properties; + } + + public static async Task CreateWithQueue(ServiceBusAdministrationClient adminClient) + { + var queueName = Guid.NewGuid().ToString("D"); + var response = await adminClient.CreateQueueAsync(queueName).ConfigureAwait(false); + return new QueueScope(adminClient, queueName, response.Value); + } + + public async ValueTask DisposeAsync() => + await _adminClient.DeleteQueueAsync(QueueName).ConfigureAwait(false); + } +} diff --git a/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/TopicScope.cs b/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/TopicScope.cs new file mode 100644 index 000000000..7f931caf8 --- /dev/null +++ b/test/Elastic.Apm.Azure.ServiceBus.Tests/Azure/TopicScope.cs @@ -0,0 +1,46 @@ +// 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.Threading.Tasks; +using Azure.Messaging.ServiceBus.Administration; + +namespace Elastic.Apm.Azure.ServiceBus.Tests.Azure +{ + public class TopicScope : IAsyncDisposable + { + private readonly ServiceBusAdministrationClient _adminClient; + public string TopicName { get; } + public string SubscriptionName { get; } + + private TopicScope(ServiceBusAdministrationClient adminClient, string topicName, string subscriptionName) + { + _adminClient = adminClient; + TopicName = topicName; + SubscriptionName = subscriptionName; + } + + public static async Task CreateWithTopic(ServiceBusAdministrationClient adminClient) + { + var topicName = Guid.NewGuid().ToString("D"); + var response = await adminClient.CreateTopicAsync(topicName).ConfigureAwait(false); + return new TopicScope(adminClient, topicName, null); + } + + public static async Task CreateWithTopicAndSubscription(ServiceBusAdministrationClient adminClient) + { + var topicName = Guid.NewGuid().ToString("D"); + var subscriptionName = Guid.NewGuid().ToString("D"); + var topicResponse = await adminClient.CreateTopicAsync(topicName).ConfigureAwait(false); + var subscriptionResponse = + await adminClient.CreateSubscriptionAsync(topicName, subscriptionName).ConfigureAwait(false); + return new TopicScope(adminClient, topicName, subscriptionName); + } + + + public async ValueTask DisposeAsync() => + await _adminClient.DeleteQueueAsync(TopicName).ConfigureAwait(false); + } +} diff --git a/test/Elastic.Apm.Azure.ServiceBus.Tests/AzureMessagingServiceBusDiagnosticListenerTests.cs b/test/Elastic.Apm.Azure.ServiceBus.Tests/AzureMessagingServiceBusDiagnosticListenerTests.cs new file mode 100644 index 000000000..942048535 --- /dev/null +++ b/test/Elastic.Apm.Azure.ServiceBus.Tests/AzureMessagingServiceBusDiagnosticListenerTests.cs @@ -0,0 +1,285 @@ +using System; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; +using Elastic.Apm.Api; +using Elastic.Apm.Azure.ServiceBus.Tests.Azure; +using Elastic.Apm.Logging; +using Elastic.Apm.Tests.Utilities; +using Elastic.Apm.Tests.Utilities.XUnit; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Elastic.Apm.Azure.ServiceBus.Tests +{ + [Collection("AzureServiceBus")] + public class AzureMessagingServiceBusDiagnosticListenerTests : IDisposable, IAsyncDisposable + { + private readonly AzureServiceBusTestEnvironment _environment; + private readonly ApmAgent _agent; + private readonly MockPayloadSender _sender; + private readonly ServiceBusClient _client; + private readonly ServiceBusAdministrationClient _adminClient; + + public AzureMessagingServiceBusDiagnosticListenerTests(AzureServiceBusTestEnvironment environment, ITestOutputHelper output) + { + _environment = environment; + + var logger = new XUnitLogger(LogLevel.Trace, output); + _sender = new MockPayloadSender(logger); + _agent = new ApmAgent(new TestAgentComponents(logger: logger, payloadSender: _sender)); + _agent.Subscribe(new AzureMessagingServiceBusDiagnosticsSubscriber()); + + _adminClient = new ServiceBusAdministrationClient(environment.ServiceBusConnectionString); + _client = new ServiceBusClient(environment.ServiceBusConnectionString); + } + + [AzureCredentialsFact] + public async Task Capture_Span_When_Send_To_Queue() + { + await using var scope = await QueueScope.CreateWithQueue(_adminClient); + var sender = _client.CreateSender(scope.QueueName); + await _agent.Tracer.CaptureTransaction("Send AzureServiceBus Message", "message", async () => + { + await sender.SendMessageAsync(new ServiceBusMessage("test message")).ConfigureAwait(false); + }); + + if (!_sender.WaitForSpans()) + throw new Exception("No span received in timeout"); + + _sender.Spans.Should().HaveCount(1); + var span = _sender.FirstSpan; + + span.Name.Should().Be($"{ServiceBus.SegmentName} SEND to {scope.QueueName}"); + span.Type.Should().Be(ApiConstants.TypeMessaging); + span.Subtype.Should().Be(ServiceBus.SubType); + span.Action.Should().Be("send"); + span.Context.Destination.Should().NotBeNull(); + var destination = span.Context.Destination; + + destination.Address.Should().Be(_environment.ServiceBusConnectionStringProperties.FullyQualifiedNamespace); + destination.Service.Name.Should().Be(ServiceBus.SubType); + destination.Service.Resource.Should().Be($"{ServiceBus.SubType}/{scope.QueueName}"); + destination.Service.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Span_When_Send_To_Topic() + { + await using var scope = await TopicScope.CreateWithTopic(_adminClient); + var sender = _client.CreateSender(scope.TopicName); + await _agent.Tracer.CaptureTransaction("Send AzureServiceBus Message", "message", async () => + { + await sender.SendMessageAsync(new ServiceBusMessage("test message")).ConfigureAwait(false); + }); + + if (!_sender.WaitForSpans()) + throw new Exception("No span received in timeout"); + + _sender.Spans.Should().HaveCount(1); + var span = _sender.FirstSpan; + + span.Name.Should().Be($"{ServiceBus.SegmentName} SEND to {scope.TopicName}"); + span.Type.Should().Be(ApiConstants.TypeMessaging); + span.Subtype.Should().Be(ServiceBus.SubType); + span.Action.Should().Be("send"); + span.Context.Destination.Should().NotBeNull(); + var destination = span.Context.Destination; + + destination.Address.Should().Be(_environment.ServiceBusConnectionStringProperties.FullyQualifiedNamespace); + destination.Service.Name.Should().Be(ServiceBus.SubType); + destination.Service.Resource.Should().Be($"{ServiceBus.SubType}/{scope.TopicName}"); + destination.Service.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Span_When_Schedule_To_Queue() + { + await using var scope = await QueueScope.CreateWithQueue(_adminClient); + var sender = _client.CreateSender(scope.QueueName); + await _agent.Tracer.CaptureTransaction("Schedule AzureServiceBus Message", "message", async () => + { + await sender.ScheduleMessageAsync( + new ServiceBusMessage("test message"), + DateTimeOffset.Now.AddSeconds(10)).ConfigureAwait(false); + }); + + if (!_sender.WaitForSpans()) + throw new Exception("No span received in timeout"); + + _sender.Spans.Should().HaveCount(1); + var span = _sender.FirstSpan; + + span.Name.Should().Be($"{ServiceBus.SegmentName} SCHEDULE to {scope.QueueName}"); + span.Type.Should().Be(ApiConstants.TypeMessaging); + span.Subtype.Should().Be(ServiceBus.SubType); + span.Action.Should().Be("schedule"); + span.Context.Destination.Should().NotBeNull(); + var destination = span.Context.Destination; + + destination.Address.Should().Be(_environment.ServiceBusConnectionStringProperties.FullyQualifiedNamespace); + destination.Service.Name.Should().Be(ServiceBus.SubType); + destination.Service.Resource.Should().Be($"{ServiceBus.SubType}/{scope.QueueName}"); + destination.Service.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Span_When_Schedule_To_Topic() + { + await using var scope = await TopicScope.CreateWithTopic(_adminClient); + var sender = _client.CreateSender(scope.TopicName); + await _agent.Tracer.CaptureTransaction("Schedule AzureServiceBus Message", "message", async () => + { + await sender.ScheduleMessageAsync( + new ServiceBusMessage("test message"), + DateTimeOffset.Now.AddSeconds(10)).ConfigureAwait(false); + }); + + if (!_sender.WaitForSpans()) + throw new Exception("No span received in timeout"); + + _sender.Spans.Should().HaveCount(1); + var span = _sender.FirstSpan; + + span.Name.Should().Be($"{ServiceBus.SegmentName} SCHEDULE to {scope.TopicName}"); + span.Type.Should().Be(ApiConstants.TypeMessaging); + span.Subtype.Should().Be(ServiceBus.SubType); + span.Action.Should().Be("schedule"); + span.Context.Destination.Should().NotBeNull(); + var destination = span.Context.Destination; + + destination.Address.Should().Be(_environment.ServiceBusConnectionStringProperties.FullyQualifiedNamespace); + destination.Service.Name.Should().Be(ServiceBus.SubType); + destination.Service.Resource.Should().Be($"{ServiceBus.SubType}/{scope.TopicName}"); + destination.Service.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Transaction_When_Receive_From_Queue() + { + await using var scope = await QueueScope.CreateWithQueue(_adminClient); + var sender = _client.CreateSender(scope.QueueName); + var receiver = _client.CreateReceiver(scope.QueueName); + + await sender.SendMessageAsync( + new ServiceBusMessage("test message")).ConfigureAwait(false); + + await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + + if (!_sender.WaitForTransactions(TimeSpan.FromMinutes(2))) + throw new Exception("No transaction received in timeout"); + + _sender.Transactions.Should().HaveCount(1); + var transaction = _sender.FirstTransaction; + + transaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVE from {scope.QueueName}"); + transaction.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Transaction_When_Receive_From_Topic_Subscription() + { + await using var scope = await TopicScope.CreateWithTopicAndSubscription(_adminClient); + + var sender = _client.CreateSender(scope.TopicName); + var receiver = _client.CreateReceiver(scope.TopicName, scope.SubscriptionName); + + await sender.SendMessageAsync( + new ServiceBusMessage("test message")).ConfigureAwait(false); + + await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + + if (!_sender.WaitForTransactions(TimeSpan.FromMinutes(2))) + throw new Exception("No transaction received in timeout"); + + _sender.Transactions.Should().HaveCount(1); + var transaction = _sender.FirstTransaction; + + transaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVE from {scope.TopicName}/Subscriptions/{scope.SubscriptionName}"); + transaction.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Transaction_When_ReceiveDeferred_From_Queue() + { + await using var scope = await QueueScope.CreateWithQueue(_adminClient); + var sender = _client.CreateSender(scope.QueueName); + var receiver = _client.CreateReceiver(scope.QueueName); + + await sender.SendMessageAsync( + new ServiceBusMessage("test message")).ConfigureAwait(false); + + + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + await receiver.DeferMessageAsync(message).ConfigureAwait(false); + + + await receiver.ReceiveDeferredMessageAsync(message.SequenceNumber).ConfigureAwait(false); + + if (!_sender.WaitForTransactions(TimeSpan.FromMinutes(2), count: 2)) + throw new Exception("No transaction received in timeout"); + + _sender.Transactions.Should().HaveCount(2); + + var transaction = _sender.FirstTransaction; + transaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVE from {scope.QueueName}"); + transaction.Type.Should().Be(ApiConstants.TypeMessaging); + + var secondTransaction = _sender.Transactions[1]; + secondTransaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVEDEFERRED from {scope.QueueName}"); + secondTransaction.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Transaction_When_ReceiveDeferred_From_Topic_Subscription() + { + await using var scope = await TopicScope.CreateWithTopicAndSubscription(_adminClient); + + var sender = _client.CreateSender(scope.TopicName); + var receiver = _client.CreateReceiver(scope.TopicName, scope.SubscriptionName); + + await sender.SendMessageAsync( + new ServiceBusMessage("test message")).ConfigureAwait(false); + + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + await receiver.DeferMessageAsync(message).ConfigureAwait(false); + + await receiver.ReceiveDeferredMessageAsync(message.SequenceNumber).ConfigureAwait(false); + + if (!_sender.WaitForTransactions(TimeSpan.FromMinutes(2), count: 2)) + throw new Exception("No transaction received in timeout"); + + _sender.Transactions.Should().HaveCount(2); + + var transaction = _sender.FirstTransaction; + transaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVE from {scope.TopicName}/Subscriptions/{scope.SubscriptionName}"); + transaction.Type.Should().Be(ApiConstants.TypeMessaging); + + var secondTransaction = _sender.Transactions[1]; + secondTransaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVEDEFERRED from {scope.TopicName}/Subscriptions/{scope.SubscriptionName}"); + secondTransaction.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Does_Not_Capture_Span_When_QueueName_Matches_IgnoreMessageQueues() + { + await using var scope = await QueueScope.CreateWithQueue(_adminClient); + var sender = _client.CreateSender(scope.QueueName); + _agent.ConfigStore.CurrentSnapshot = new MockConfigSnapshot(ignoreMessageQueues: scope.QueueName); + + await _agent.Tracer.CaptureTransaction("Send AzureServiceBus Message", "message", async () => + { + await sender.SendMessageAsync(new ServiceBusMessage("test message")).ConfigureAwait(false); + }); + + _sender.SignalEndSpans(); + _sender.WaitForSpans(); + _sender.Spans.Should().HaveCount(0); + } + + public void Dispose() => _agent.Dispose(); + + public ValueTask DisposeAsync() => _client.DisposeAsync(); + } +} diff --git a/test/Elastic.Apm.Azure.ServiceBus.Tests/Elastic.Apm.Azure.ServiceBus.Tests.csproj b/test/Elastic.Apm.Azure.ServiceBus.Tests/Elastic.Apm.Azure.ServiceBus.Tests.csproj new file mode 100644 index 000000000..65a695ff7 --- /dev/null +++ b/test/Elastic.Apm.Azure.ServiceBus.Tests/Elastic.Apm.Azure.ServiceBus.Tests.csproj @@ -0,0 +1,29 @@ + + + + net5.0 + false + Elastic.Apm.Azure.ServiceBus.Tests + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + diff --git a/test/Elastic.Apm.Azure.ServiceBus.Tests/MicrosoftAzureServiceBusDiagnosticListenerTests.cs b/test/Elastic.Apm.Azure.ServiceBus.Tests/MicrosoftAzureServiceBusDiagnosticListenerTests.cs new file mode 100644 index 000000000..1c281225c --- /dev/null +++ b/test/Elastic.Apm.Azure.ServiceBus.Tests/MicrosoftAzureServiceBusDiagnosticListenerTests.cs @@ -0,0 +1,283 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus.Administration; +using Elastic.Apm.Api; +using Elastic.Apm.Azure.ServiceBus.Tests.Azure; +using Elastic.Apm.Logging; +using Elastic.Apm.Tests.Utilities; +using Elastic.Apm.Tests.Utilities.XUnit; +using FluentAssertions; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Xunit; +using Xunit.Abstractions; + +namespace Elastic.Apm.Azure.ServiceBus.Tests +{ + [Collection("AzureServiceBus")] + public class MicrosoftAzureServiceBusDiagnosticListenerTests : IDisposable + { + private readonly AzureServiceBusTestEnvironment _environment; + private readonly ApmAgent _agent; + private readonly MockPayloadSender _sender; + private readonly ServiceBusAdministrationClient _adminClient; + + public MicrosoftAzureServiceBusDiagnosticListenerTests(AzureServiceBusTestEnvironment environment, ITestOutputHelper output) + { + _environment = environment; + + var logger = new XUnitLogger(LogLevel.Trace, output); + _sender = new MockPayloadSender(logger); + _agent = new ApmAgent(new TestAgentComponents(logger: logger, payloadSender: _sender)); + _agent.Subscribe(new MicrosoftAzureServiceBusDiagnosticsSubscriber()); + _adminClient = new ServiceBusAdministrationClient(environment.ServiceBusConnectionString); + } + + [AzureCredentialsFact] + public async Task Capture_Span_When_Send_To_Queue() + { + await using var scope = await QueueScope.CreateWithQueue(_adminClient); + var sender = new MessageSender(_environment.ServiceBusConnectionString, scope.QueueName); + + await _agent.Tracer.CaptureTransaction("Send AzureServiceBus Message", "message", async () => + { + await sender.SendAsync(new Message(Encoding.UTF8.GetBytes("test message"))).ConfigureAwait(false); + }); + + if (!_sender.WaitForSpans()) + throw new Exception("No span received in timeout"); + + _sender.Spans.Should().HaveCount(1); + var span = _sender.FirstSpan; + + span.Name.Should().Be($"{ServiceBus.SegmentName} SEND to {scope.QueueName}"); + span.Type.Should().Be(ApiConstants.TypeMessaging); + span.Subtype.Should().Be(ServiceBus.SubType); + span.Action.Should().Be("send"); + span.Context.Destination.Should().NotBeNull(); + var destination = span.Context.Destination; + + destination.Address.Should().Be($"sb://{_environment.ServiceBusConnectionStringProperties.FullyQualifiedNamespace}/"); + destination.Service.Name.Should().Be(ServiceBus.SubType); + destination.Service.Resource.Should().Be($"{ServiceBus.SubType}/{scope.QueueName}"); + destination.Service.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Span_When_Send_To_Topic() + { + await using var scope = await TopicScope.CreateWithTopic(_adminClient); + var sender = new MessageSender(_environment.ServiceBusConnectionString, scope.TopicName); + await _agent.Tracer.CaptureTransaction("Send AzureServiceBus Message", "message", async () => + { + await sender.SendAsync(new Message(Encoding.UTF8.GetBytes("test message"))).ConfigureAwait(false); + }); + + if (!_sender.WaitForSpans()) + throw new Exception("No span received in timeout"); + + _sender.Spans.Should().HaveCount(1); + var span = _sender.FirstSpan; + + span.Name.Should().Be($"{ServiceBus.SegmentName} SEND to {scope.TopicName}"); + span.Type.Should().Be(ApiConstants.TypeMessaging); + span.Subtype.Should().Be(ServiceBus.SubType); + span.Action.Should().Be("send"); + span.Context.Destination.Should().NotBeNull(); + var destination = span.Context.Destination; + + destination.Address.Should().Be($"sb://{_environment.ServiceBusConnectionStringProperties.FullyQualifiedNamespace}/"); + destination.Service.Name.Should().Be(ServiceBus.SubType); + destination.Service.Resource.Should().Be($"{ServiceBus.SubType}/{scope.TopicName}"); + destination.Service.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Span_When_Schedule_To_Queue() + { + await using var scope = await QueueScope.CreateWithQueue(_adminClient); + var sender = new MessageSender(_environment.ServiceBusConnectionString, scope.QueueName); + await _agent.Tracer.CaptureTransaction("Schedule AzureServiceBus Message", "message", async () => + { + await sender.ScheduleMessageAsync( + new Message(Encoding.UTF8.GetBytes("test message")), + DateTimeOffset.Now.AddSeconds(10)).ConfigureAwait(false); + }); + + if (!_sender.WaitForSpans()) + throw new Exception("No span received in timeout"); + + _sender.Spans.Should().HaveCount(1); + var span = _sender.FirstSpan; + + span.Name.Should().Be($"{ServiceBus.SegmentName} SCHEDULE to {scope.QueueName}"); + span.Type.Should().Be(ApiConstants.TypeMessaging); + span.Subtype.Should().Be(ServiceBus.SubType); + span.Action.Should().Be("schedule"); + span.Context.Destination.Should().NotBeNull(); + var destination = span.Context.Destination; + + destination.Address.Should().Be($"sb://{_environment.ServiceBusConnectionStringProperties.FullyQualifiedNamespace}/"); + destination.Service.Name.Should().Be(ServiceBus.SubType); + destination.Service.Resource.Should().Be($"{ServiceBus.SubType}/{scope.QueueName}"); + destination.Service.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Span_When_Schedule_To_Topic() + { + await using var scope = await TopicScope.CreateWithTopic(_adminClient); + var sender = new MessageSender(_environment.ServiceBusConnectionString, scope.TopicName); + await _agent.Tracer.CaptureTransaction("Schedule AzureServiceBus Message", "message", async () => + { + await sender.ScheduleMessageAsync( + new Message(Encoding.UTF8.GetBytes("test message")), + DateTimeOffset.Now.AddSeconds(10)).ConfigureAwait(false); + }); + + if (!_sender.WaitForSpans()) + throw new Exception("No span received in timeout"); + + _sender.Spans.Should().HaveCount(1); + var span = _sender.FirstSpan; + + span.Name.Should().Be($"{ServiceBus.SegmentName} SCHEDULE to {scope.TopicName}"); + span.Type.Should().Be(ApiConstants.TypeMessaging); + span.Subtype.Should().Be(ServiceBus.SubType); + span.Action.Should().Be("schedule"); + span.Context.Destination.Should().NotBeNull(); + var destination = span.Context.Destination; + + destination.Address.Should().Be($"sb://{_environment.ServiceBusConnectionStringProperties.FullyQualifiedNamespace}/"); + destination.Service.Name.Should().Be(ServiceBus.SubType); + destination.Service.Resource.Should().Be($"{ServiceBus.SubType}/{scope.TopicName}"); + destination.Service.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Transaction_When_Receive_From_Queue() + { + await using var scope = await QueueScope.CreateWithQueue(_adminClient); + var sender = new MessageSender(_environment.ServiceBusConnectionString, scope.QueueName); + var receiver = new MessageReceiver(_environment.ServiceBusConnectionString, scope.QueueName, ReceiveMode.PeekLock); + + await sender.SendAsync( + new Message(Encoding.UTF8.GetBytes("test message"))).ConfigureAwait(false); + + await receiver.ReceiveAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + + if (!_sender.WaitForTransactions(TimeSpan.FromMinutes(2))) + throw new Exception("No transaction received in timeout"); + + _sender.Transactions.Should().HaveCount(1); + var transaction = _sender.FirstTransaction; + + transaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVE from {scope.QueueName}"); + transaction.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Transaction_When_Receive_From_Topic_Subscription() + { + await using var scope = await TopicScope.CreateWithTopicAndSubscription(_adminClient); + + var sender = new MessageSender(_environment.ServiceBusConnectionString, scope.TopicName); + var receiver = new MessageReceiver(_environment.ServiceBusConnectionString, + EntityNameHelper.FormatSubscriptionPath(scope.TopicName, scope.SubscriptionName)); + + await sender.SendAsync( + new Message(Encoding.UTF8.GetBytes("test message"))).ConfigureAwait(false); + + await receiver.ReceiveAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + + if (!_sender.WaitForTransactions(TimeSpan.FromMinutes(2))) + throw new Exception("No transaction received in timeout"); + + _sender.Transactions.Should().HaveCount(1); + var transaction = _sender.FirstTransaction; + + transaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVE from {scope.TopicName}/Subscriptions/{scope.SubscriptionName}"); + transaction.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Transaction_When_ReceiveDeferred_From_Queue() + { + await using var scope = await QueueScope.CreateWithQueue(_adminClient); + var sender = new MessageSender(_environment.ServiceBusConnectionString, scope.QueueName); + var receiver = new MessageReceiver(_environment.ServiceBusConnectionString, scope.QueueName, ReceiveMode.PeekLock); + + await sender.SendAsync( + new Message(Encoding.UTF8.GetBytes("test message"))).ConfigureAwait(false); + + var message = await receiver.ReceiveAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + await receiver.DeferAsync(message.SystemProperties.LockToken).ConfigureAwait(false); + + await receiver.ReceiveDeferredMessageAsync(message.SystemProperties.SequenceNumber).ConfigureAwait(false); + + if (!_sender.WaitForTransactions(TimeSpan.FromMinutes(2), count: 2)) + throw new Exception("No transaction received in timeout"); + + _sender.Transactions.Should().HaveCount(2); + + var transaction = _sender.FirstTransaction; + transaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVE from {scope.QueueName}"); + transaction.Type.Should().Be(ApiConstants.TypeMessaging); + + var secondTransaction = _sender.Transactions[1]; + secondTransaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVEDEFERRED from {scope.QueueName}"); + secondTransaction.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Capture_Transaction_When_ReceiveDeferred_From_Topic_Subscription() + { + await using var scope = await TopicScope.CreateWithTopicAndSubscription(_adminClient); + + var sender = new MessageSender(_environment.ServiceBusConnectionString, scope.TopicName); + var receiver = new MessageReceiver(_environment.ServiceBusConnectionString, + EntityNameHelper.FormatSubscriptionPath(scope.TopicName, scope.SubscriptionName)); + + await sender.SendAsync( + new Message(Encoding.UTF8.GetBytes("test message"))).ConfigureAwait(false); + + var message = await receiver.ReceiveAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + await receiver.DeferAsync(message.SystemProperties.LockToken).ConfigureAwait(false); + + await receiver.ReceiveDeferredMessageAsync(message.SystemProperties.SequenceNumber).ConfigureAwait(false); + + if (!_sender.WaitForTransactions(TimeSpan.FromMinutes(2), count: 2)) + throw new Exception("No transaction received in timeout"); + + _sender.Transactions.Should().HaveCount(2); + + var transaction = _sender.FirstTransaction; + transaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVE from {scope.TopicName}/Subscriptions/{scope.SubscriptionName}"); + transaction.Type.Should().Be(ApiConstants.TypeMessaging); + + var secondTransaction = _sender.Transactions[1]; + secondTransaction.Name.Should().Be($"{ServiceBus.SegmentName} RECEIVEDEFERRED from {scope.TopicName}/Subscriptions/{scope.SubscriptionName}"); + secondTransaction.Type.Should().Be(ApiConstants.TypeMessaging); + } + + [AzureCredentialsFact] + public async Task Does_Not_Capture_Span_When_QueueName_Matches_IgnoreMessageQueues() + { + await using var scope = await QueueScope.CreateWithQueue(_adminClient); + var sender = new MessageSender(_environment.ServiceBusConnectionString, scope.QueueName); + _agent.ConfigStore.CurrentSnapshot = new MockConfigSnapshot(ignoreMessageQueues: scope.QueueName); + + await _agent.Tracer.CaptureTransaction("Send AzureServiceBus Message", "message", async () => + { + await sender.SendAsync(new Message(Encoding.UTF8.GetBytes("test message"))).ConfigureAwait(false); + }); + + _sender.SignalEndSpans(); + _sender.WaitForSpans(); + _sender.Spans.Should().HaveCount(0); + } + + public void Dispose() => _agent.Dispose(); + } +} diff --git a/test/Elastic.Apm.Azure.ServiceBus.Tests/Terraform/TerraformResourceException.cs b/test/Elastic.Apm.Azure.ServiceBus.Tests/Terraform/TerraformResourceException.cs new file mode 100644 index 000000000..d7da010f1 --- /dev/null +++ b/test/Elastic.Apm.Azure.ServiceBus.Tests/Terraform/TerraformResourceException.cs @@ -0,0 +1,27 @@ +// 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.Linq; + +namespace Elastic.Apm.Azure.ServiceBus.Tests.Terraform +{ + /// + /// An exception from interacting with terraform resources. + /// + public class TerraformResourceException : Exception + { + public TerraformResourceException(string message, int exitCode, List output) + : base(string.Join(Environment.NewLine, new [] { message, $"exit code: {exitCode}", "output:" }.Concat(output))) + { + } + + public TerraformResourceException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/test/Elastic.Apm.Azure.ServiceBus.Tests/Terraform/TerraformResources.cs b/test/Elastic.Apm.Azure.ServiceBus.Tests/Terraform/TerraformResources.cs new file mode 100644 index 000000000..c56a8a654 --- /dev/null +++ b/test/Elastic.Apm.Azure.ServiceBus.Tests/Terraform/TerraformResources.cs @@ -0,0 +1,166 @@ +// 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.Runtime.ExceptionServices; +using System.Text; +using Elastic.Apm.Azure.ServiceBus.Tests.Azure; +using ProcNet; +using ProcNet.Std; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Elastic.Apm.Azure.ServiceBus.Tests.Terraform +{ + /// + /// Interact with Terraform templates to apply and destroy resources + /// + public class TerraformResources + { + private static readonly TimeSpan _defaultTimeout = TimeSpan.FromMinutes(10); + + private readonly string _resourceDirectory; + private readonly IMessageSink _messageSink; + private readonly AzureCredentials _credentials; + + public TerraformResources(string resourceDirectory, AzureCredentials credentials, IMessageSink messageSink = null) + { + if (resourceDirectory is null) + throw new ArgumentNullException(nameof(resourceDirectory)); + + if (!Directory.Exists(resourceDirectory)) + throw new DirectoryNotFoundException($"Directory does not exist {resourceDirectory}"); + + _resourceDirectory = resourceDirectory; + _credentials = credentials; + _messageSink = messageSink; + } + + private ObservableProcess CreateProcess(params string[] arguments) + { + var startArguments = new StartArguments("terraform", arguments) + { + WorkingDirectory = _resourceDirectory + }; + _credentials.AddToArguments(startArguments); + + return new ObservableProcess(startArguments); + } + + private void RunProcess(ObservableProcess process, Action onLine = null) + { + var capturedLines = new List(); + ExceptionDispatchInfo e = null; + + process.SubscribeLines(line => + { + capturedLines.Add(line.Line); + onLine?.Invoke(line); + }, + exception => e = ExceptionDispatchInfo.Capture(exception)); + + var completed = process.WaitForCompletion(_defaultTimeout); + + if (!completed) + { + process.Dispose(); + throw new TerraformResourceException( + $"terraform {_resourceDirectory} timed out after {_defaultTimeout}", -1, capturedLines); + } + + if (e != null) + { + throw new TerraformResourceException( + $"terraform {_resourceDirectory} did not succeed", e.SourceException); + } + + if (process.ExitCode != 0) + { + throw new TerraformResourceException( + $"terraform {_resourceDirectory} did not succeed", process.ExitCode.Value, capturedLines); + } + } + + public void Init() + { + using var process = CreateProcess("init", "-no-color"); + RunProcess(process, _messageSink is null ? null: line => _messageSink.OnMessage(new DiagnosticMessage(line.Line))); + } + + /// + /// Applies the terraform infrastructure with the supplied variables + /// + /// + public void Apply(IDictionary variables = null) + { + var args = new List + { + "apply", + "-auto-approve", + "-no-color", + "-input=false" + }; + + if (variables != null) + { + foreach (var variable in variables) + { + args.Add("-var"); + args.Add($"{variable.Key}={variable.Value}"); + } + } + + using var process = CreateProcess(args.ToArray()); + RunProcess(process, _messageSink is null ? null: line => _messageSink.OnMessage(new DiagnosticMessage(line.Line))); + } + + /// + /// Reads an output value from applied terraform managed infrastructure. + /// + /// The name of the output value to read + /// + public string Output(string name) + { + var output = new StringBuilder(); + using var process = CreateProcess($"output", "-raw", "-no-color", name); + RunProcess(process, line => + { + if (!line.Error) + output.Append(line.Line); + }); + + return output.ToString(); + } + + /// + /// Destroys the terraform managed infrastructure + /// + /// + public void Destroy(IDictionary variables = null) + { + var args = new List + { + "destroy", + "-auto-approve", + "-no-color", + "-input=false" + }; + + if (variables != null) + { + foreach (var variable in variables) + { + args.Add("-var"); + args.Add($"{variable.Key}={variable.Value}"); + } + } + + using var process = CreateProcess(args.ToArray()); + RunProcess(process); + } + } +} diff --git a/test/Elastic.Apm.Benchmarks/PropertyFetcherBenchmark.cs b/test/Elastic.Apm.Benchmarks/PropertyFetcherBenchmark.cs index f35a871b6..3c80144f8 100644 --- a/test/Elastic.Apm.Benchmarks/PropertyFetcherBenchmark.cs +++ b/test/Elastic.Apm.Benchmarks/PropertyFetcherBenchmark.cs @@ -6,6 +6,7 @@ using System.Reflection; using BenchmarkDotNet.Attributes; using Elastic.Apm.Helpers; +using Elastic.Apm.Reflection; using Microsoft.Data.SqlClient; namespace Elastic.Apm.Benchmarks diff --git a/test/Elastic.Apm.Tests.Utilities/Elastic.Apm.Tests.Utilities.csproj b/test/Elastic.Apm.Tests.Utilities/Elastic.Apm.Tests.Utilities.csproj index aa820e3aa..b0fbca0f1 100644 --- a/test/Elastic.Apm.Tests.Utilities/Elastic.Apm.Tests.Utilities.csproj +++ b/test/Elastic.Apm.Tests.Utilities/Elastic.Apm.Tests.Utilities.csproj @@ -23,6 +23,7 @@ + diff --git a/test/Elastic.Apm.Tests.Utilities/MockConfigSnapshot.cs b/test/Elastic.Apm.Tests.Utilities/MockConfigSnapshot.cs index c5d067e90..1539d2978 100644 --- a/test/Elastic.Apm.Tests.Utilities/MockConfigSnapshot.cs +++ b/test/Elastic.Apm.Tests.Utilities/MockConfigSnapshot.cs @@ -30,6 +30,7 @@ public class MockConfigSnapshot : AbstractConfigurationReader, IConfigSnapshot private readonly string _flushInterval; private readonly string _globalLabels; private readonly string _hostName; + private readonly string _ignoreMessageQueues; private readonly string _logLevel; private readonly string _maxBatchEventCount; private readonly string _maxQueueEventCount; @@ -87,7 +88,8 @@ public MockConfigSnapshot(IApmLogger logger = null, string enabled = null, string recording = null, string serverUrl = null, - string serverCert = null + string serverCert = null, + string ignoreMessageQueues = null ) : base(logger, ThisClassName) { _serverUrls = serverUrls; @@ -125,6 +127,7 @@ public MockConfigSnapshot(IApmLogger logger = null, _recording = recording; _serverUrl = serverUrl; _serverCert = serverCert; + _ignoreMessageQueues = ignoreMessageQueues; } public string ApiKey => ParseApiKey(Kv(EnvVarNames.ApiKey, _apiKey, Origin)); @@ -160,6 +163,9 @@ public MockConfigSnapshot(IApmLogger logger = null, public string HostName => ParseHostName(Kv(EnvVarNames.HostName, _hostName, Origin)); + public IReadOnlyList IgnoreMessageQueues => + ParseIgnoreMessageQueues(Kv(EnvVarNames.IgnoreMessageQueues, _ignoreMessageQueues, Origin)); + public LogLevel LogLevel => ParseLogLevel(Kv(EnvVarNames.LogLevel, _logLevel, Origin)); public int MaxBatchEventCount => ParseMaxBatchEventCount(Kv(EnvVarNames.MaxBatchEventCount, _maxBatchEventCount, Origin)); public int MaxQueueEventCount => ParseMaxQueueEventCount(Kv(EnvVarNames.MaxQueueEventCount, _maxQueueEventCount, Origin)); diff --git a/test/Elastic.Apm.Tests.Utilities/SolutionPaths.cs b/test/Elastic.Apm.Tests.Utilities/SolutionPaths.cs index 72fd84627..9bf7a7a94 100644 --- a/test/Elastic.Apm.Tests.Utilities/SolutionPaths.cs +++ b/test/Elastic.Apm.Tests.Utilities/SolutionPaths.cs @@ -27,6 +27,9 @@ private static string FindSolutionRoot() throw new InvalidOperationException($"Could not find solution root directory from the current directory `{currentDirectory}'"); } + /// + /// The full path to the solution root + /// public static string Root => _root.Value; } } diff --git a/test/Elastic.Apm.Tests/BackendCommTests/CentralConfig/CentralConfigFetcherTests.cs b/test/Elastic.Apm.Tests/BackendCommTests/CentralConfig/CentralConfigFetcherTests.cs index 9529168ed..c7df9289a 100644 --- a/test/Elastic.Apm.Tests/BackendCommTests/CentralConfig/CentralConfigFetcherTests.cs +++ b/test/Elastic.Apm.Tests/BackendCommTests/CentralConfig/CentralConfigFetcherTests.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information using System; -using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -132,6 +132,51 @@ public void Should_Not_Update_Logger_That_Is_Not_ILogLevelSwitchable() testLogger.LogLevelSwitch.Level.Should().Be(LogLevel.Trace); } + [Fact] + public void Should_Update_IgnoreMessageQueues_Configuration() + { + var configSnapshotFromReader = new MockConfigSnapshot(LoggerBase, ignoreMessageQueues: ""); + var configStore = new ConfigStore(configSnapshotFromReader, LoggerBase); + + configStore.CurrentSnapshot.IgnoreMessageQueues.Should().BeEmpty(); + + var service = Service.GetDefaultService(configSnapshotFromReader, LoggerBase); + var waitHandle = new ManualResetEvent(false); + var handler = new MockHttpMessageHandler(); + var configUrl = BackendCommUtils.ApmServerEndpoints + .BuildGetConfigAbsoluteUrl(configSnapshotFromReader.ServerUrl, service); + + handler.When(configUrl.AbsoluteUri) + .Respond(_ => + { + waitHandle.Set(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Headers = { ETag = new EntityTagHeaderValue("\"etag\"") }, + Content = new StringContent("{ \"ignore_message_queues\": \"foo\" }", Encoding.UTF8) + }; + }); + + var centralConfigFetcher = new CentralConfigFetcher(LoggerBase, configStore, service, handler); + + using var agent = new ApmAgent(new TestAgentComponents(LoggerBase, + centralConfigFetcher: centralConfigFetcher, + payloadSender: new NoopPayloadSender())); + + centralConfigFetcher.IsRunning.Should().BeTrue(); + waitHandle.WaitOne(); + + // wait up to 60 seconds for configuration to change. Change can often be slower in CI + var count = 0; + while (count < 60 && !configStore.CurrentSnapshot.IgnoreMessageQueues.Any()) + { + count++; + Thread.Sleep(TimeSpan.FromSeconds(1)); + } + + configStore.CurrentSnapshot.IgnoreMessageQueues.Should().NotBeEmpty().And.Contain(m => m.GetMatcher() == "foo"); + } + [Fact] public void Dispose_stops_the_thread() { diff --git a/test/Elastic.Apm.Tests/ConstructorTests.cs b/test/Elastic.Apm.Tests/ConstructorTests.cs index e8d84f1bf..b40ecea14 100644 --- a/test/Elastic.Apm.Tests/ConstructorTests.cs +++ b/test/Elastic.Apm.Tests/ConstructorTests.cs @@ -64,6 +64,7 @@ private class LogConfig : IConfigSnapshot public string ServiceName { get; } public string ServiceVersion { get; } public IReadOnlyList DisableMetrics => ConfigConsts.DefaultValues.DisableMetrics; + public IReadOnlyList IgnoreMessageQueues => ConfigConsts.DefaultValues.IgnoreMessageQueues; public double SpanFramesMinDurationInMilliseconds => ConfigConsts.DefaultValues.SpanFramesMinDurationInMilliseconds; public int StackTraceLimit => ConfigConsts.DefaultValues.StackTraceLimit; public double TransactionSampleRate => ConfigConsts.DefaultValues.TransactionSampleRate; diff --git a/test/Elastic.Apm.Tests/HelpersTests/PropertyFetcherTests.cs b/test/Elastic.Apm.Tests/HelpersTests/PropertyFetcherTests.cs index 6739d449b..8ad9e3cc6 100644 --- a/test/Elastic.Apm.Tests/HelpersTests/PropertyFetcherTests.cs +++ b/test/Elastic.Apm.Tests/HelpersTests/PropertyFetcherTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using Elastic.Apm.Helpers; +using Elastic.Apm.Reflection; using FluentAssertions; using Xunit;