Skip to content

Commit

Permalink
Test startup hook instrumentation of dotnet new projects
Browse files Browse the repository at this point in the history
  • Loading branch information
russcam committed Feb 5, 2021
1 parent 9db704f commit 43d0195
Show file tree
Hide file tree
Showing 4 changed files with 280 additions and 47 deletions.
123 changes: 123 additions & 0 deletions test/Elastic.Apm.StartupHook.Tests/DotnetProject.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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.IO.Compression;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading;
using ProcNet;
using ProcNet.Std;

namespace Elastic.Apm.StartupHook.Tests
{
public class DotnetProject : IDisposable
{
private ObservableProcess _process;

private DotnetProject(string name, string template, string directory)
{
Name = name;
Template = template;
Directory = directory;
}

public string Directory { get; }

public string Template { get; }

public string Name { get; }

/// <summary>
/// Creates a process to run the dotnet project that will start when subscribed to.
/// </summary>
/// <param name="startupHookZipPath">The path to the startup hook zip file</param>
/// <param name="environmentVariables">The environment variables to start the project with. The DOTNET_STARTUP_HOOKS environment variable will be added.</param>
/// <returns></returns>
public ObservableProcess CreateProcess(string startupHookZipPath, IDictionary<string, string> environmentVariables = null)
{
var startupHookAssembly = UnzipStartupHook(startupHookZipPath);
environmentVariables ??= new Dictionary<string, string>();
environmentVariables["DOTNET_STARTUP_HOOKS"] = startupHookAssembly;
var arguments = new StartArguments("dotnet", "run")
{
WorkingDirectory = Directory,
SendControlCFirst = true,
Environment = environmentVariables
};

_process = new ObservableProcess(arguments);
return _process;
}

/// <summary>
/// Unzips the agent startup hook zip file to the temp directory, and returns
/// the path to the startup hook assembly.
/// </summary>
/// <returns></returns>
/// <exception cref="FileNotFoundException">
/// Startup hook assembly not found in the extracted files.
/// </exception>
private string UnzipStartupHook(string startupHookZipPath)
{
var tempDirectory = Path.GetTempPath();
var destination = Path.Combine(tempDirectory, Guid.NewGuid().ToString());

ZipFile.ExtractToDirectory(startupHookZipPath, destination);
var startupHookAssembly = Path.Combine(destination, "ElasticApmAgentStartupHook.dll");

if (!File.Exists(startupHookAssembly))
throw new FileNotFoundException($"startup hook assembly does not exist at {startupHookAssembly}", startupHookAssembly);

return startupHookAssembly;
}

public void Stop()
{
if (_process?.ProcessId != null)
{
_process.SendControlC();
_process.Dispose();
}
}

public void Dispose() => Stop();


public static DotnetProject Create(string name, string template, params string[] arguments)
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());

var args = new[]
{
"new", template,
"--name", name,
"--output", directory
}.Concat(arguments);

var result = Proc.Start(new StartArguments("dotnet", args));

if (result.Completed)
{
if (!result.ExitCode.HasValue || result.ExitCode != 0)
{
throw new Exception($"Creating new dotnet project did not exit successfully. "
+ $"exit code: {result.ExitCode}, "
+ $"output: {string.Join(Environment.NewLine, result.ConsoleOut.Select(c => c.Line))}");
}
}
else
{
throw new Exception($"Creating new dotnet project did not complete. "
+ $"exit code: {result.ExitCode}, "
+ $"output: {string.Join(Environment.NewLine, result.ConsoleOut.Select(c => c.Line))}");
}

return new DotnetProject(name, template, directory);
}
}
}
47 changes: 2 additions & 45 deletions test/Elastic.Apm.StartupHook.Tests/SampleApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading;
using ProcNet;
Expand All @@ -19,52 +18,10 @@ namespace Elastic.Apm.StartupHook.Tests
/// </summary>
public class SampleApplication : IDisposable
{
private static readonly string SolutionRoot;

private static string FindSolutionRoot()
{
var solutionFileName = "ElasticApmAgent.sln";
var currentDirectory = Directory.GetCurrentDirectory();
var candidateDirectory = new DirectoryInfo(currentDirectory);
do
{
if (File.Exists(Path.Combine(candidateDirectory.FullName, solutionFileName)))
return candidateDirectory.FullName;

candidateDirectory = candidateDirectory.Parent;
} while (candidateDirectory != null);

throw new InvalidOperationException($"Could not find solution root directory from the current directory `{currentDirectory}'");
}

private static string FindVersionedAgentZip()
{
var buildOutputDir = Path.Combine(SolutionRoot, "build/output");
if (!Directory.Exists(buildOutputDir))
{
throw new DirectoryNotFoundException(
$"build output directory does not exist at {buildOutputDir}. "
+ $"Run the build script in the solution root with agent-zip target to build");
}

var agentZip = Directory.EnumerateFiles(buildOutputDir, "ElasticApmAgent_*.zip", SearchOption.TopDirectoryOnly)
.FirstOrDefault();

if (agentZip is null)
{
throw new FileNotFoundException($"ElasticApmAgent_*.zip file not found in {buildOutputDir}. "
+ $"Run the build script in the solution root with agent-zip target to build");
}

return agentZip;
}

static SampleApplication() => SolutionRoot = FindSolutionRoot();

private readonly string _startupHookZipPath;
private ObservableProcess _process;

public SampleApplication() : this(FindVersionedAgentZip())
public SampleApplication() : this(SolutionPaths.AgentZip)
{
}

Expand Down Expand Up @@ -93,7 +50,7 @@ public Uri Start(string targetFramework, IDictionary<string, string> environment
environmentVariables["DOTNET_STARTUP_HOOKS"] = startupHookAssembly;
var arguments = new StartArguments("dotnet", "run", "-f", targetFramework)
{
WorkingDirectory = Path.Combine(SolutionRoot, "sample", "Elastic.Apm.StartupHook.Sample"),
WorkingDirectory = Path.Combine(SolutionPaths.Root, "sample", "Elastic.Apm.StartupHook.Sample"),
SendControlCFirst = true,
Environment = environmentVariables
};
Expand Down
58 changes: 58 additions & 0 deletions test/Elastic.Apm.StartupHook.Tests/SolutionPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 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.IO;
using System.Linq;

namespace Elastic.Apm.StartupHook.Tests
{
public static class SolutionPaths
{
private static readonly Lazy<string> _root = new Lazy<string>(FindSolutionRoot);

private static readonly Lazy<string> _agentZip = new Lazy<string>(FindVersionedAgentZip);
private static string FindSolutionRoot()
{
var solutionFileName = "ElasticApmAgent.sln";
var currentDirectory = Directory.GetCurrentDirectory();
var candidateDirectory = new DirectoryInfo(currentDirectory);
do
{
if (File.Exists(Path.Combine(candidateDirectory.FullName, solutionFileName)))
return candidateDirectory.FullName;

candidateDirectory = candidateDirectory.Parent;
} while (candidateDirectory != null);

throw new InvalidOperationException($"Could not find solution root directory from the current directory `{currentDirectory}'");
}

private static string FindVersionedAgentZip()
{
var buildOutputDir = Path.Combine(Root, "build/output");
if (!Directory.Exists(buildOutputDir))
{
throw new DirectoryNotFoundException(
$"build output directory does not exist at {buildOutputDir}. "
+ $"Run the build script in the solution root with agent-zip target to build");
}

var agentZip = Directory.EnumerateFiles(buildOutputDir, "ElasticApmAgent_*.zip", SearchOption.TopDirectoryOnly)
.FirstOrDefault();

if (agentZip is null)
{
throw new FileNotFoundException($"ElasticApmAgent_*.zip file not found in {buildOutputDir}. "
+ $"Run the build script in the solution root with agent-zip target to build");
}

return agentZip;
}

public static string Root => _root.Value;
public static string AgentZip => _agentZip.Value;
}
}
99 changes: 97 additions & 2 deletions test/Elastic.Apm.StartupHook.Tests/StartupHookTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Runtime.ExceptionServices;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Elastic.Apm.Tests.MockApmServer;
Expand Down Expand Up @@ -59,7 +61,6 @@ public async Task Auto_Instrument_With_StartupHook_Should_Capture_Transaction(st
waitHandle.Set();
};

// block until a transaction is received, or 2 minute timeout
waitHandle.WaitOne(TimeSpan.FromMinutes(2));
apmServer.ReceivedData.Transactions.Should().HaveCount(1);

Expand Down Expand Up @@ -106,7 +107,6 @@ public async Task Auto_Instrument_With_StartupHook_Should_Capture_Metadata(
waitHandle.Set();
};

// block until a transaction is received, or 2 minute timeout
waitHandle.WaitOne(TimeSpan.FromMinutes(2));
apmServer.ReceivedData.Metadata.Should().HaveCount(1);

Expand All @@ -118,5 +118,100 @@ public async Task Auto_Instrument_With_StartupHook_Should_Capture_Metadata(
sampleApp.Stop();
await apmServer.StopAsync();
}

[Theory]
[InlineData("webapi", "WebApi30", "netcoreapp3.0", "weatherforecast")]
[InlineData("webapi", "WebApi31", "netcoreapp3.1", "weatherforecast")]
[InlineData("webapi", "WebApi50", "net5.0", "weatherforecast")]
[InlineData("webapp", "WebApp30", "netcoreapp3.0", "")]
[InlineData("webapp", "WebApp31", "netcoreapp3.1", "")]
[InlineData("webapp", "WebApp50", "net5.0", "")]
[InlineData("mvc", "Mvc30", "netcoreapp3.0", "")]
[InlineData("mvc", "Mvc31", "netcoreapp3.1", "")]
[InlineData("mvc", "Mvc50", "net5.0", "")]
public async Task Auto_Instrument_With_StartupHook(string template, string name, string targetFramework, string path)
{
var apmLogger = new InMemoryBlockingLogger(LogLevel.Trace);
var apmServer = new MockApmServer(apmLogger, nameof(Auto_Instrument_With_StartupHook));
var port = apmServer.FindAvailablePortToListen();
apmServer.RunInBackground(port);

using var project = DotnetProject.Create(name, template, "--framework", targetFramework, "--no-https");
var environmentVariables = new Dictionary<string, string>
{
[EnvVarNames.ServerUrl] = $"http://localhost:{port}",
[EnvVarNames.CloudProvider] = "none"
};

var process = project.CreateProcess(SolutionPaths.AgentZip, environmentVariables);
var startHandle = new ManualResetEvent(false);
Uri uri = null;
ExceptionDispatchInfo e = null;
var capturedLines = new List<string>();
var endpointRegex = new Regex(@"\s*Now listening on:\s*(?<endpoint>[^\s]*)");

process.SubscribeLines(
line =>
{
capturedLines.Add(line.Line);
var match = endpointRegex.Match(line.Line);
if (match.Success)
{
try
{
var endpoint = match.Groups["endpoint"].Value.Trim();
uri = new UriBuilder(endpoint) { Path = path }.Uri;
}
catch (Exception exception)
{
e = ExceptionDispatchInfo.Capture(exception);
}

startHandle.Set();
}
},
exception => e = ExceptionDispatchInfo.Capture(exception));

var timeout = TimeSpan.FromMinutes(2);
var signalled = startHandle.WaitOne(timeout);
if (!signalled)
{
throw new Exception($"Could not start dotnet project within timeout {timeout}: "
+ string.Join(Environment.NewLine, capturedLines));
}

e?.Throw();

var client = new HttpClient();
var response = await client.GetAsync(uri);

response.IsSuccessStatusCode.Should().BeTrue();

var waitHandle = new ManualResetEvent(false);

apmServer.OnReceive += o =>
{
if (o is TransactionDto)
waitHandle.Set();
};

// block until a transaction is received, or 2 minute timeout
signalled = waitHandle.WaitOne(timeout);
if (!signalled)
{
throw new Exception($"Did not receive transaction within timeout {timeout}: "
+ string.Join(Environment.NewLine, capturedLines)
+ Environment.NewLine
+ string.Join(Environment.NewLine, apmLogger.Lines));
}

apmServer.ReceivedData.Transactions.Should().HaveCount(1);

var transaction = apmServer.ReceivedData.Transactions.First();
transaction.Name.Should().NotBeNullOrEmpty();

project.Stop();
await apmServer.StopAsync();
}
}
}

0 comments on commit 43d0195

Please sign in to comment.