Skip to content

Commit

Permalink
Implement User-Agent spec for transport (#1518)
Browse files Browse the repository at this point in the history
This commit implements the User-Agent feature.
Update User-Agent sent by the agent to match the spec.
HttpClient parses apm-agent-dotnet/<version> (<service> <version>)
as two separate User-Agent header values:

apm-agent-dotnet/<version>
(<service> <version>)

- Refactor outcome steps to use ScenarioContext.
- Introduce TestConfiguration to allow configuration to be provided a step
at a time.
- Collect all payloads sent to mock HttpMessageHandler.

Co-authored-by: Russ Cam <[email protected]>
  • Loading branch information
apmmachine and russcam authored Nov 9, 2021
1 parent afbc9e7 commit e953480
Show file tree
Hide file tree
Showing 10 changed files with 353 additions and 120 deletions.
17 changes: 13 additions & 4 deletions src/Elastic.Apm/BackendComm/BackendCommUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,7 @@ internal static HttpClient BuildHttpClient(IApmLogger loggerArg, IConfiguration
, serverUrlBase.Sanitize(), dbgCallerDesc);
var httpClient =
new HttpClient(httpMessageHandler ?? CreateHttpClientHandler(configuration, loggerArg)) { BaseAddress = serverUrlBase };
httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue($"elasticapm-{Consts.AgentName}", AdaptUserAgentValue(service.Agent.Version)));
httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(GetUserAgent(service));
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("System.Net.Http",
AdaptUserAgentValue(typeof(HttpClient).Assembly.GetCustomAttribute<AssemblyFileVersionAttribute>().Version)));

Expand All @@ -213,10 +212,20 @@ internal static HttpClient BuildHttpClient(IApmLogger loggerArg, IConfiguration

// Replace invalid characters by underscore. All invalid characters can be found at
// https://github.com/dotnet/corefx/blob/e64cac6dcacf996f98f0b3f75fb7ad0c12f588f7/src/System.Net.Http/src/System/Net/Http/HttpRuleParser.cs#L41
string AdaptUserAgentValue(string value)
}

private static string GetUserAgent(Service service)
{
var value = $"apm-agent-{Consts.AgentName}/{AdaptUserAgentValue(service.Agent.Version)}";
if (!string.IsNullOrEmpty(service.Name))
{
return Regex.Replace(value, "[ /()<>@,:;={}?\\[\\]\"\\\\]", "_");
value += !string.IsNullOrEmpty(service.Version)
? $" ({AdaptUserAgentValue(service.Name)} {AdaptUserAgentValue(service.Version)})"
: $" ({AdaptUserAgentValue(service.Name)})";
}
return value;
}

private static string AdaptUserAgentValue(string value) => Regex.Replace(value, "[ /()<>@,:;={}?\\[\\]\"\\\\]", "_");
}
}
1 change: 1 addition & 0 deletions test/Elastic.Apm.Feature.Tests/ApiKeySteps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace Elastic.Apm.Feature.Tests
{
[Binding]
[Scope(Feature = "API Key.")]
public class ApiKeySteps
{
private readonly ApiKeyFeatureContext _apiKeyFeatureContext;
Expand Down
57 changes: 10 additions & 47 deletions test/Elastic.Apm.Feature.Tests/CloudProviderSteps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,14 @@
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using Elastic.Apm.Api;
using Elastic.Apm.Logging;
using Elastic.Apm.Report;
using Elastic.Apm.Tests.Utilities;
using Elastic.Apm.Tests.Utilities.XUnit;
using FluentAssertions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using RichardSzalay.MockHttp;
using TechTalk.SpecFlow;
Expand All @@ -28,7 +23,7 @@ namespace Elastic.Apm.Feature.Tests
[Binding]
public class CloudProviderSteps
{
private static readonly JsonSerializer _serializer = new JsonSerializer();

private readonly ScenarioContext _scenarioContext;

public CloudProviderSteps(ScenarioContext scenarioContext) => _scenarioContext = scenarioContext;
Expand All @@ -47,13 +42,7 @@ public void AgentWithCloudMetadata(string cloudProvider)
handler.When(BuildIntakeV2EventsAbsoluteUrl(config.ServerUrl).AbsoluteUri)
.Respond(r =>
{
var json = r.Content.ReadAsStringAsync().Result;
if (json.Contains("\"metadata\""))
{
payloadCollector.Request = ParseJObjects(json);
payloadCollector.WaitHandle.Set();
}

payloadCollector.ProcessPayload(r);
return new HttpResponseMessage(HttpStatusCode.OK);
});

Expand All @@ -69,10 +58,7 @@ public void AgentWithCloudMetadata(string cloudProvider)
handler,
environmentVariables: environmentVariables);

var lazyAgent = new Lazy<ApmAgent>(() =>
new ApmAgent(new TestAgentComponents(logger, config, payloadSender)));

_scenarioContext.Set(lazyAgent);
_scenarioContext.Set(() => new ApmAgent(new TestAgentComponents(logger, config, payloadSender)));
}

[Given("^the following environment variables are present$")]
Expand All @@ -87,26 +73,22 @@ public void EnvironmentVariablesSet(Table table)
[When("^cloud metadata is collected$")]
public void CollectCloudMetadata()
{
var lazyAgent = _scenarioContext.Get<Lazy<ApmAgent>>();
// create the agent and capture a transaction to send metadata
var agent = lazyAgent.Value;
var agent = _scenarioContext.Get<ApmAgent>();
agent.Tracer.CaptureTransaction("Transaction", "feature", () => { });

var payloadCollector = _scenarioContext.Get<PayloadCollector>();

// wait for the wait handle to be signalled
var timeout = TimeSpan.FromSeconds(30);
if (!payloadCollector.WaitHandle.Wait(timeout))
throw new Exception($"Did not receive payload within {timeout}");
payloadCollector.Wait();
}

[Then("^cloud metadata is not null$")]
public void CloudMetadataIsNotNull()
{
var payloadCollector = _scenarioContext.Get<PayloadCollector>();

payloadCollector.Request.Should().NotBeNull();
var cloudMetadata = payloadCollector.Request[0]["metadata"]["cloud"];
payloadCollector.Payloads.Should().NotBeEmpty();
var cloudMetadata = payloadCollector.Payloads[0].Body[0]["metadata"]["cloud"];
cloudMetadata.Should().NotBeNull();
}

Expand All @@ -115,38 +97,19 @@ public void CloudMetadataIsNull()
{
var payloadCollector = _scenarioContext.Get<PayloadCollector>();

payloadCollector.Request.Should().NotBeNull();
var cloudMetadata = payloadCollector.Request[0]["metadata"]["cloud"];
payloadCollector.Payloads.Should().NotBeEmpty();
var cloudMetadata = payloadCollector.Payloads[0].Body[0]["metadata"]["cloud"];
cloudMetadata.Should().BeNull();
}

[Then("^cloud metadata '(.*?)' is '(.*?)'$")]
public void CloudMetadataKeyEqualsValue(string key, string value)
{
var payloadCollector = _scenarioContext.Get<PayloadCollector>();
var token = payloadCollector.Request[0].SelectToken($"metadata.cloud.{key}");
var token = payloadCollector.Payloads[0].Body[0].SelectToken($"metadata.cloud.{key}");

token.Should().NotBeNull();
token.Value<string>().Should().Be(value);
}

private class PayloadCollector
{
public ManualResetEventSlim WaitHandle { get; }

public PayloadCollector() => WaitHandle = new ManualResetEventSlim(false);

public List<JObject> Request { get; set; }
}

private static List<JObject> ParseJObjects(string json)
{
var jObjects = new List<JObject>();
using var stringReader = new StringReader(json);
using var jsonReader = new JsonTextReader(stringReader) { SupportMultipleContent = true };
while (jsonReader.Read())
jObjects.Add(_serializer.Deserialize<JObject>(jsonReader));
return jObjects;
}
}
}
37 changes: 37 additions & 0 deletions test/Elastic.Apm.Feature.Tests/ConfigurationSteps.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// 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 TechTalk.SpecFlow;

namespace Elastic.Apm.Feature.Tests
{
[Binding]
public class ConfigurationSteps
{
private readonly ScenarioContext _scenarioContext;

public ConfigurationSteps(ScenarioContext scenarioContext) => _scenarioContext = scenarioContext;

[When("^service name is not set$")]
public void WhenServiceNameIsNotSet() { }

[When("^service version is not set$")]
public void WhenServiceVersionIsNotSet() { }

[When("^service name is set to '(.*?)'$")]
public void WhenServiceNameIsSetTo(string name)
{
var configuration = _scenarioContext.Get<TestConfiguration>();
configuration.ServiceName = name;
}

[When("^service version is set to '(.*?)'$")]
public void WhenServiceVersionIsSetTo(string version)
{
var configuration = _scenarioContext.Get<TestConfiguration>();
configuration.ServiceVersion = version;
}
}
}
19 changes: 19 additions & 0 deletions test/Elastic.Apm.Feature.Tests/Features/user_agent.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Feature: Agent Transport User agent Header

Scenario: Default user-agent
Given an agent
When service name is not set
When service version is not set
Then the User-Agent header matches regex '^apm-agent-[a-z]+/[^ ]*'

Scenario: User-agent with service name only
Given an agent
When service name is set to 'myService'
When service version is not set
Then the User-Agent header matches regex '^apm-agent-[a-z]+/[^ ]* \(myService\)'

Scenario: User-agent with service name and service version
Given an agent
When service name is set to 'myService'
When service version is set to 'v42'
Then the User-Agent header matches regex '^apm-agent-[a-z]+/[^ ]* \(myService v42\)'
Loading

0 comments on commit e953480

Please sign in to comment.