innerDictionary) =>
+ _innerDictionary = innerDictionary;
+
+ public void Add(string key, object value)
+ {
+ if (++_itemCount > MaximumDepth)
+ throw new InvalidOperationException("Request too large");
+
+ _innerDictionary.Add(key, value);
+ }
+
+ private static int GetMaximumDepth()
+ {
+ var appSettings = System.Configuration.ConfigurationManager.AppSettings;
+ var valueArray = appSettings?.GetValues("aspnet:MaxJsonDeserializerMembers");
+ if (valueArray == null || valueArray.Length <= 0) return 1000;
+ return int.TryParse(valueArray[0], out var result) ? result : 1000;
+ }
+ }
+ }
+}
diff --git a/sample/AspNetFullFrameworkSampleApp/Mvc/StreamResult.cs b/sample/AspNetFullFrameworkSampleApp/Mvc/StreamResult.cs
new file mode 100644
index 000000000..f5c0727d2
--- /dev/null
+++ b/sample/AspNetFullFrameworkSampleApp/Mvc/StreamResult.cs
@@ -0,0 +1,45 @@
+// 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.IO;
+using System.Web.Mvc;
+
+namespace AspNetFullFrameworkSampleApp.Mvc
+{
+ public class StreamResult : ActionResult
+ {
+ private const int BufferSize = 4096;
+ private readonly Stream _stream;
+ private readonly string _contentType;
+ private readonly int _statusCode;
+
+ public StreamResult(Stream stream, string contentType, int statusCode = 200)
+ {
+ _stream = stream;
+ _contentType = contentType;
+ _statusCode = statusCode;
+ }
+
+ public override void ExecuteResult(ControllerContext context)
+ {
+ var response = context.RequestContext.HttpContext.Response;
+ response.StatusCode = _statusCode;
+ response.ContentType = _contentType;
+ var outputStream = response.OutputStream;
+ using (_stream)
+ {
+ var buffer = new byte[BufferSize];
+ while (true)
+ {
+ var count = _stream.Read(buffer, 0, BufferSize);
+ if (count != 0)
+ outputStream.Write(buffer, 0, count);
+ else
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/sample/AspNetFullFrameworkSampleApp/Views/Database/Create.cshtml b/sample/AspNetFullFrameworkSampleApp/Views/Database/Create.cshtml
new file mode 100644
index 000000000..aad9eb9f8
--- /dev/null
+++ b/sample/AspNetFullFrameworkSampleApp/Views/Database/Create.cshtml
@@ -0,0 +1,18 @@
+@model CreateSampleDataViewModel
+
+@using (Html.BeginForm("Create", "Database", FormMethod.Post, new { id = "createForm", @class = "form-horizontal", role = "form" }))
+{
+ @Html.AntiForgeryToken()
+ Add a new sample
+
+ @Html.ValidationSummary(true, "", new { @class = "text-danger" })
+
+ @Html.Hidden("samples.Index", "index")
+ @Html.Label("samples[index].Name")
+ @Html.TextBox("samples[index].Name", "", new { @class = "form-control" })
+ @Html.ValidationMessage("samples[index].Name", "", new { @class = "text-danger" })
+
+
+
+
+}
\ No newline at end of file
diff --git a/sample/AspNetFullFrameworkSampleApp/Views/Database/Index.cshtml b/sample/AspNetFullFrameworkSampleApp/Views/Database/Index.cshtml
new file mode 100644
index 000000000..be9914f53
--- /dev/null
+++ b/sample/AspNetFullFrameworkSampleApp/Views/Database/Index.cshtml
@@ -0,0 +1,26 @@
+@model IEnumerable
+
+@if (!Model.Any())
+{
+ No samples in the database.
+}
+else
+{
+
+
+
+ Id |
+ Name |
+
+
+
+ @foreach (var sample in Model)
+ {
+
+ @sample.Id |
+ @sample.Name |
+
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs b/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs
index 19d435892..38958e812 100644
--- a/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs
+++ b/src/Elastic.Apm.AspNetFullFramework/ElasticApmModule.cs
@@ -24,6 +24,7 @@ internal static class OpenIdClaimTypes
internal const string Email = "email";
internal const string UserId = "sub";
}
+
public class ElasticApmModule : IHttpModule
{
private static bool _isCaptureHeadersEnabled;
@@ -36,10 +37,6 @@ public class ElasticApmModule : IHttpModule
// ReSharper disable once ImpureMethodCallOnReadonlyValueField
public ElasticApmModule() => _dbgInstanceName = DbgInstanceNameGenerator.Generate($"{nameof(ElasticApmModule)}.#");
- // We can store current transaction because each IHttpModule is used for at most one request at a time
- // For example see https://bytes.com/topic/asp-net/answers/324305-httpmodule-multithreading-request-response-corelation
- private ITransaction _currentTransaction;
-
private HttpApplication _httpApp;
private IApmLogger _logger;
@@ -126,14 +123,17 @@ private void ProcessBeginRequest(object eventSender)
if (soapAction != null) transactionName += $" {soapAction}";
var distributedTracingData = ExtractIncomingDistributedTracingData(httpRequest);
+ ITransaction transaction;
+
if (distributedTracingData != null)
{
_logger.Debug()
?.Log(
"Incoming request with {TraceParentHeaderName} header. DistributedTracingData: {DistributedTracingData} - continuing trace",
DistributedTracing.TraceContext.TraceParentHeaderNamePrefixed, distributedTracingData);
+
// we set ignoreActivity to true to avoid the HttpContext W3C DiagnosticSource issue (see https://github.com/elastic/apm-agent-dotnet/issues/867#issuecomment-650170150)
- _currentTransaction = Agent.Instance.Tracer.StartTransaction(transactionName, ApiConstants.TypeRequest, distributedTracingData, true);
+ transaction = Agent.Instance.Tracer.StartTransaction(transactionName, ApiConstants.TypeRequest, distributedTracingData, true);
}
else
{
@@ -141,10 +141,10 @@ private void ProcessBeginRequest(object eventSender)
?.Log("Incoming request doesn't have valid incoming distributed tracing data - starting trace with new trace ID");
// we set ignoreActivity to true to avoid the HttpContext W3C DiagnosticSource issue(see https://github.com/elastic/apm-agent-dotnet/issues/867#issuecomment-650170150)
- _currentTransaction = Agent.Instance.Tracer.StartTransaction(transactionName, ApiConstants.TypeRequest, ignoreActivity: true);
+ transaction = Agent.Instance.Tracer.StartTransaction(transactionName, ApiConstants.TypeRequest, ignoreActivity: true);
}
- if (_currentTransaction.IsSampled) FillSampledTransactionContextRequest(httpRequest, _currentTransaction);
+ if (transaction.IsSampled) FillSampledTransactionContextRequest(httpRequest, transaction);
}
///
@@ -242,11 +242,11 @@ private void ProcessEndRequest(object eventSender)
var httpApp = (HttpApplication)eventSender;
var httpCtx = httpApp.Context;
var httpResponse = httpCtx.Response;
- var transaction = _currentTransaction;
+ var transaction = Agent.Instance.Tracer.CurrentTransaction;
if (transaction == null) return;
- SendErrorEventIfPresent(httpCtx);
+ SendErrorEventIfPresent(httpCtx, transaction);
// update the transaction name based on route values, if applicable
if (transaction is Transaction t && !t.HasCustomName)
@@ -280,7 +280,7 @@ private void ProcessEndRequest(object eventSender)
_logger?.Trace()?.Log("Calculating transaction name based on route data");
var name = Transaction.GetNameFromRouteContext(routeData);
- if (!string.IsNullOrWhiteSpace(name)) _currentTransaction.Name = $"{httpCtx.Request.HttpMethod} {name}";
+ if (!string.IsNullOrWhiteSpace(name)) transaction.Name = $"{httpCtx.Request.HttpMethod} {name}";
}
else
{
@@ -292,27 +292,27 @@ private void ProcessEndRequest(object eventSender)
}
}
- _currentTransaction.Result = Transaction.StatusCodeToResult("HTTP", httpResponse.StatusCode);
+ transaction.Result = Transaction.StatusCodeToResult("HTTP", httpResponse.StatusCode);
if (httpResponse.StatusCode >= 500)
- _currentTransaction.Outcome = Outcome.Failure;
+ transaction.Outcome = Outcome.Failure;
else
- _currentTransaction.Outcome = Outcome.Success;
+ transaction.Outcome = Outcome.Success;
- if (_currentTransaction.IsSampled)
+ if (transaction.IsSampled)
{
- FillSampledTransactionContextResponse(httpResponse, _currentTransaction);
- FillSampledTransactionContextUser(httpCtx, _currentTransaction);
+ FillSampledTransactionContextResponse(httpResponse, transaction);
+ FillSampledTransactionContextUser(httpCtx, transaction);
}
- _currentTransaction.End();
- _currentTransaction = null;
+ transaction.End();
+ transaction = null;
}
- private void SendErrorEventIfPresent(HttpContext httpCtx)
+ private void SendErrorEventIfPresent(HttpContext httpCtx, ITransaction transaction)
{
var lastError = httpCtx.Server.GetLastError();
- if (lastError != null) _currentTransaction.CaptureException(lastError);
+ if (lastError != null) transaction.CaptureException(lastError);
}
private static void FillSampledTransactionContextResponse(HttpResponse httpResponse, ITransaction transaction) =>
@@ -420,7 +420,13 @@ private static AgentComponents BuildAgentComponents(string dbgInstanceName)
var reader = ConfigHelper.CreateReader(rootLogger) ?? new FullFrameworkConfigReader(rootLogger);
- var agentComponents = new AgentComponents(rootLogger, reader);
+ var agentComponents = new AgentComponents(
+ rootLogger,
+ reader,
+ null,
+ null,
+ new HttpContextCurrentExecutionSegmentsContainer(),
+ null);
var aspNetVersion = FindAspNetVersion(scopedLogger);
diff --git a/src/Elastic.Apm.AspNetFullFramework/HttpContextCurrentExecutionSegmentsContainer.cs b/src/Elastic.Apm.AspNetFullFramework/HttpContextCurrentExecutionSegmentsContainer.cs
new file mode 100644
index 000000000..5f3172b7a
--- /dev/null
+++ b/src/Elastic.Apm.AspNetFullFramework/HttpContextCurrentExecutionSegmentsContainer.cs
@@ -0,0 +1,48 @@
+// 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.Threading;
+using System.Web;
+using Elastic.Apm.Model;
+
+namespace Elastic.Apm.AspNetFullFramework
+{
+ ///
+ /// An that stores the current transaction
+ /// and current span in both async local storage and the current
+ ///
+ internal sealed class HttpContextCurrentExecutionSegmentsContainer : ICurrentExecutionSegmentsContainer
+ {
+ private readonly AsyncLocal _currentSpan = new AsyncLocal();
+ private readonly AsyncLocal _currentTransaction = new AsyncLocal();
+
+ private const string CurrentSpanKey = "Elastic.Apm.Agent.CurrentSpan";
+ private const string CurrentTransactionKey = "Elastic.Apm.Agent.CurrentTransaction";
+
+ public Span CurrentSpan
+ {
+ get => _currentSpan.Value ?? HttpContext.Current?.Items[CurrentSpanKey] as Span;
+ set
+ {
+ _currentSpan.Value = value;
+ var httpContext = HttpContext.Current;
+ if (httpContext != null)
+ httpContext.Items[CurrentSpanKey] = value;
+ }
+ }
+
+ public Transaction CurrentTransaction
+ {
+ get => _currentTransaction.Value ?? HttpContext.Current?.Items[CurrentTransactionKey] as Transaction;
+ set
+ {
+ _currentTransaction.Value = value;
+ var httpContext = HttpContext.Current;
+ if (httpContext != null)
+ httpContext.Items[CurrentTransactionKey] = value;
+ }
+ }
+ }
+}
diff --git a/src/Elastic.Apm/ICurrentExecutionSegmentsContainer.cs b/src/Elastic.Apm/ICurrentExecutionSegmentsContainer.cs
index 545737356..ef4f8f975 100644
--- a/src/Elastic.Apm/ICurrentExecutionSegmentsContainer.cs
+++ b/src/Elastic.Apm/ICurrentExecutionSegmentsContainer.cs
@@ -8,7 +8,14 @@ namespace Elastic.Apm
{
internal interface ICurrentExecutionSegmentsContainer
{
+ ///
+ /// Gets or sets the current span
+ ///
Span CurrentSpan { get; set; }
+
+ ///
+ /// Gets or sets the current transaction
+ ///
Transaction CurrentTransaction { get; set; }
}
}
diff --git a/test/Elastic.Apm.AspNetFullFramework.Tests/Elastic.Apm.AspNetFullFramework.Tests.csproj b/test/Elastic.Apm.AspNetFullFramework.Tests/Elastic.Apm.AspNetFullFramework.Tests.csproj
index d4c1706d9..5c0c72bf4 100644
--- a/test/Elastic.Apm.AspNetFullFramework.Tests/Elastic.Apm.AspNetFullFramework.Tests.csproj
+++ b/test/Elastic.Apm.AspNetFullFramework.Tests/Elastic.Apm.AspNetFullFramework.Tests.csproj
@@ -28,7 +28,7 @@
-
+
TargetFramework=net461
diff --git a/test/Elastic.Apm.AspNetFullFramework.Tests/HttpContextCurrentExecutionSegmentsContainerTests.cs b/test/Elastic.Apm.AspNetFullFramework.Tests/HttpContextCurrentExecutionSegmentsContainerTests.cs
new file mode 100644
index 000000000..e7db33d55
--- /dev/null
+++ b/test/Elastic.Apm.AspNetFullFramework.Tests/HttpContextCurrentExecutionSegmentsContainerTests.cs
@@ -0,0 +1,158 @@
+// 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.Globalization;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using AspNetFullFrameworkSampleApp.Models;
+using FluentAssertions;
+using Newtonsoft.Json;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Elastic.Apm.AspNetFullFramework.Tests
+{
+ [Collection(Consts.AspNetFullFrameworkTestsCollection)]
+ public class HttpContextCurrentExecutionSegmentsContainerTests : TestsBase
+ {
+ public HttpContextCurrentExecutionSegmentsContainerTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper)
+ {
+ }
+
+ [AspNetFullFrameworkFact]
+ public async Task Transaction_And_Spans_Captured_When_Large_Request()
+ {
+ var samples = Enumerable.Range(1, 1_000)
+ .Select(i => new CreateSampleDataViewModel { Name = $"Sample {i}" });
+
+ var json = JsonConvert.SerializeObject(samples);
+ var bytes = Encoding.UTF8.GetByteCount(json);
+
+ // larger than 20Kb
+ bytes.Should().BeGreaterThan(20_000);
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var client = new HttpClient();
+ var bulkSamplesUri = Consts.SampleApp.CreateUrl("/Database/Bulk");
+ var response = await client.PostAsync(bulkSamplesUri, content).ConfigureAwait(false);
+
+ var responseContent = await response.Content.ReadAsStringAsync();
+ response.IsSuccessStatusCode.Should().BeTrue(responseContent);
+
+ await WaitAndCustomVerifyReceivedData(received =>
+ {
+ received.Transactions.Count.Should().Be(1);
+ var transaction = received.Transactions.Single();
+
+ transaction.SpanCount.Started.Should().Be(500);
+ transaction.SpanCount.Dropped.Should().Be(501);
+ received.Spans.Count.Should().Be(500);
+ });
+ }
+
+ [AspNetFullFrameworkFact]
+ public async Task Transaction_And_Spans_Captured_When_Controller_Action_Makes_Async_Http_Call()
+ {
+ var count = 100;
+ var content = new StringContent($"{{\"count\":{count}}}", Encoding.UTF8, "application/json");
+
+ var client = new HttpClient();
+ var bulkSamplesUri = Consts.SampleApp.CreateUrl("/Database/Generate");
+ var response = await client.PostAsync(bulkSamplesUri, content).ConfigureAwait(false);
+
+ var responseContent = await response.Content.ReadAsStringAsync();
+ response.IsSuccessStatusCode.Should().BeTrue(responseContent);
+
+ await WaitAndCustomVerifyReceivedData(received =>
+ {
+ received.Transactions.Count.Should().Be(2);
+ var transactions = received.Transactions
+ .OrderByDescending(t => t.Timestamp)
+ .ToList();
+
+ var firstTransaction = transactions.First();
+ firstTransaction.Name.Should().EndWith("Bulk");
+ firstTransaction.SpanCount.Started.Should().Be(100);
+
+ var secondTransaction = transactions.Last();
+ secondTransaction.Name.Should().EndWith("Generate");
+ secondTransaction.SpanCount.Started.Should().Be(3);
+
+ received.Spans.Count.Should().Be(103);
+ });
+ }
+
+ [AspNetFullFrameworkFact]
+ public async Task Transaction_And_Spans_Captured_When_Multiple_Concurrent_Requests()
+ {
+ static HttpRequestMessage CreateMessage(int i)
+ {
+ var message = new HttpRequestMessage(HttpMethod.Get, Consts.SampleApp.CreateUrl("/Home/Contact"));
+ message.Headers.Add("X-HttpRequest", i.ToString(CultureInfo.InvariantCulture));
+ return message;
+ }
+
+ var count = 9;
+ var messages = Enumerable.Range(1, count)
+ .Select(i => CreateMessage(i))
+ .ToList();
+
+ // infinite timespan
+ var client = new HttpClient { Timeout = TimeSpan.FromMilliseconds(-1) };
+
+ var tasks = new List>(messages.Count);
+ foreach (var message in messages)
+ tasks.Add(client.SendAsync(message));
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ for (var index = 0; index < tasks.Count; index++)
+ {
+ var task = tasks[index];
+ task.IsCompletedSuccessfully.Should().BeTrue();
+ var response = task.Result;
+ var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ response.IsSuccessStatusCode.Should().BeTrue($"response {index}: {responseContent}");
+ }
+
+ await WaitAndCustomVerifyReceivedData(received =>
+ {
+ received.Transactions.Count.Should().Be(count * 2);
+
+ var contactTransactions = received.Transactions
+ .Where(t => t.Name == "GET Home/Contact")
+ .ToList();
+
+ contactTransactions.Should().HaveCount(count);
+
+ var aboutTransactions = received.Transactions
+ .Where(t => t.Name == "GET Home/About")
+ .ToList();
+
+ aboutTransactions.Should().HaveCount(count);
+
+ // assert that each aboutTransaction is a child of a span associated with a contactTransaction
+ foreach (var contactTransaction in contactTransactions)
+ {
+ contactTransaction.ParentId.Should().BeNull();
+ var spans = received.Spans.Where(s => s.TransactionId == contactTransaction.Id)
+ .ToList();
+
+ spans.Should().HaveCount(2);
+
+ var localHostSpan = spans.SingleOrDefault(s => s.Name == "GET localhost");
+ localHostSpan.Should().NotBeNull();
+
+ var aboutTransaction = aboutTransactions.SingleOrDefault(t => t.ParentId == localHostSpan.Id);
+ aboutTransaction.Should().NotBeNull();
+ }
+ }).ConfigureAwait(false);
+ }
+ }
+}