Skip to content

Commit

Permalink
summary: ASP.NET Core 6+ Browser Injectionand Razor Pages Instrumenta…
Browse files Browse the repository at this point in the history
…tion

feat: Add automatic browser agent injection for ASP.NET Core v6+ web applications. 
feat: Add automatic instrumentation for ASP.NET Core v6+ Razor Pages.
  • Loading branch information
tippmar-nr committed Nov 2, 2023
1 parent 3ac75a0 commit 14c6bb1
Show file tree
Hide file tree
Showing 50 changed files with 1,746 additions and 32 deletions.
8 changes: 8 additions & 0 deletions FullAgent.sln
Expand Up @@ -192,6 +192,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Home", "src\Agent\NewRelic\
{B65A0C00-100D-4F27-BAC7-6B8A9FC7619D} = {B65A0C00-100D-4F27-BAC7-6B8A9FC7619D}
{C51E44B7-ADC9-4EDA-AAAE-F6307180A3EB} = {C51E44B7-ADC9-4EDA-AAAE-F6307180A3EB}
{C60C1767-A73A-4A9E-BAF1-D3463C7CEFEC} = {C60C1767-A73A-4A9E-BAF1-D3463C7CEFEC}
{D4F48A7F-F3D3-4303-921D-BF7FE34B7118} = {D4F48A7F-F3D3-4303-921D-BF7FE34B7118}
{D6E22195-EE69-4320-B08B-E68229FB69AB} = {D6E22195-EE69-4320-B08B-E68229FB69AB}
{D9428449-3E4B-4723-A8AA-1191315C7AAD} = {D9428449-3E4B-4723-A8AA-1191315C7AAD}
{E10BF2F9-D5CA-4330-8169-ED30D861697E} = {E10BF2F9-D5CA-4330-8169-ED30D861697E}
Expand All @@ -218,6 +219,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elasticsearch", "src\Agent\
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kafka", "src\Agent\NewRelic\Agent\Extensions\Providers\Wrapper\Kafka\Kafka.csproj", "{270A9CC8-8031-49F4-A380-1389E7517DB7}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCore6Plus", "src\Agent\NewRelic\Agent\Extensions\Providers\Wrapper\AspNetCore6Plus\AspNetCore6Plus.csproj", "{D4F48A7F-F3D3-4303-921D-BF7FE34B7118}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -452,6 +455,10 @@ Global
{270A9CC8-8031-49F4-A380-1389E7517DB7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{270A9CC8-8031-49F4-A380-1389E7517DB7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{270A9CC8-8031-49F4-A380-1389E7517DB7}.Release|Any CPU.Build.0 = Release|Any CPU
{D4F48A7F-F3D3-4303-921D-BF7FE34B7118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D4F48A7F-F3D3-4303-921D-BF7FE34B7118}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D4F48A7F-F3D3-4303-921D-BF7FE34B7118}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D4F48A7F-F3D3-4303-921D-BF7FE34B7118}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -522,6 +529,7 @@ Global
{EC34F023-223D-432F-9401-9C3ED1B75DE4} = {5E86E10A-C38F-48CB-ADE9-67B22BB2F50A}
{D9428449-3E4B-4723-A8AA-1191315C7AAD} = {5E86E10A-C38F-48CB-ADE9-67B22BB2F50A}
{270A9CC8-8031-49F4-A380-1389E7517DB7} = {5E86E10A-C38F-48CB-ADE9-67B22BB2F50A}
{D4F48A7F-F3D3-4303-921D-BF7FE34B7118} = {5E86E10A-C38F-48CB-ADE9-67B22BB2F50A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
EnterpriseLibraryConfigurationToolBinariesPath = packages\Unity.2.1.505.2\lib\NET35
Expand Down
2 changes: 2 additions & 0 deletions build/ArtifactBuilder/CoreAgentComponents.cs
Expand Up @@ -56,6 +56,7 @@ protected override void CreateAgentComponents()
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.StackExchangeRedis2Plus.dll",
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.NServiceBus.dll",
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.Kafka.dll",
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AspNetCore6Plus.dll",
};

var wrapperXmls = new[]
Expand All @@ -76,6 +77,7 @@ protected override void CreateAgentComponents()
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.StackExchangeRedis2Plus.Instrumentation.xml",
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.NServiceBus.Instrumentation.xml",
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.Kafka.Instrumentation.xml",
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AspNetCore6Plus.Instrumentation.xml",
};

ExtensionXsd = $@"{SourceHomeBuilderPath}\extensions\extension.xsd";
Expand Down
9 changes: 9 additions & 0 deletions build/build_home.ps1
Expand Up @@ -83,6 +83,15 @@ if ($melNetCorePath = Resolve-Path "$wrappersRootDir\MicrosoftExtensionsLogging\
$netFrameworkWrapperHash.Add($dllObject, $xmlObject)
}

# AspNetCore6Plus is built to target .net 6, but we'll copy it to the netstandard folder
if ($aspNetCore6PlusPath = Resolve-Path "$wrappersRootDir\AspNetCore6Plus\bin\$Configuration\net6.0") {
$dllObject = Get-ChildItem -File -Path "$aspNetCore6PlusPath" -Filter NewRelic.Providers.Wrapper.AspNetCore6Plus.dll
$xmlObject = Get-ChildItem -File -Path "$aspNetCore6PlusPath" -Filter Instrumentation.xml
$netstandard20WrapperHash.Add($dllObject, $xmlObject)
}



$netFrameworkStorageArray = @()
$netstandard20StorageArray = @()

Expand Down
6 changes: 6 additions & 0 deletions src/Agent/MsiInstaller/Installer/Product.wxs
Expand Up @@ -531,6 +531,9 @@ SPDX-License-Identifier: Apache-2.0
<Component Id="CoreKafkaWrapperComponent" Guid="{6CA6D5A4-A204-4B04-A6B5-13D8B3E736C2}">
<File Id="CoreKafkaWrapperFile" Name="NewRelic.Providers.Wrapper.Kafka.dll" KeyPath="yes" Source="$(var.HomeFolderPath)_coreclr\extensions\NewRelic.Providers.Wrapper.Kafka.dll"/>
</Component>
<Component Id="CoreAspNetCore6PlusWrapperComponent" Guid="{3B7C5E60-BA9D-4B1C-8B16-B16025B075C0}">
<File Id="CoreAspNetCore6PlusWrapperFile" Name="NewRelic.Providers.Wrapper.AspNetCore6Plus.dll" KeyPath="yes" Source="$(var.HomeFolderPath)_coreclr\extensions\NewRelic.Providers.Wrapper.AspNetCore6Plus.dll"/>
</Component>

<!-- Reference libraries -->
<Component Id="CoreNewRelicCoreReferenceComponent" Guid="{DD2BE979-7D4B-47EA-9FBE-F6B381D70E0B}">
Expand Down Expand Up @@ -690,6 +693,9 @@ SPDX-License-Identifier: Apache-2.0
<Component Id="CoreKafkaInstrumentationComponent" Guid="{B2C4F83B-A339-4DBD-B8C4-760C8F72F9FC}">
<File Id="CoreKafkaInstrumentationFile" Name="NewRelic.Providers.Wrapper.Kafka.Instrumentation.xml" KeyPath="yes" Source="$(var.HomeFolderPath)_coreclr\extensions\NewRelic.Providers.Wrapper.Kafka.Instrumentation.xml"/>
</Component>
<Component Id="CoreAspNetCore6PlusInstrumentationComponent" Guid="{1CC1E672-5AA5-4F6F-A736-748EA556653A}">
<File Id="CoreAspNetCore6PlusInstrumentationFile" Name="NewRelic.Providers.Wrapper.AspNetCore6Plus.Instrumentation.xml" KeyPath="yes" Source="$(var.HomeFolderPath)_coreclr\extensions\NewRelic.Providers.Wrapper.AspNetCore6Plus.Instrumentation.xml"/>
</Component>
</ComponentGroup>

<!-- Extensions XSD-->
Expand Down
35 changes: 27 additions & 8 deletions src/Agent/NewRelic/Agent/Core/Agent.cs
Expand Up @@ -28,8 +28,11 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Mime;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace NewRelic.Agent.Core
{
Expand Down Expand Up @@ -305,6 +308,28 @@ public Stream TryGetStreamInjector(Stream stream, Encoding encoding, string cont
return null;
}

var script = TryGetRUMScriptInternal(contentType, requestPath);
return script == null ? null : new BrowserMonitoringStreamInjector(() => script, stream, encoding);
}

public async Task TryInjectBrowserScriptAsync(string contentType, string requestPath, byte[] buffer, Stream baseStream)
{
var transaction = _transactionService.GetCurrentInternalTransaction();

var script = TryGetRUMScriptInternal(contentType, requestPath);
var rumBytes = script == null ? null : Encoding.UTF8.GetBytes(script);

if (rumBytes == null)
{
transaction.LogFinest("Skipping RUM Injection: No script was available.");
await baseStream.WriteAsync(buffer, 0, buffer.Length);
}
else
await BrowserScriptInjectionHelper.InjectBrowserScriptAsync(buffer, baseStream, rumBytes, transaction);
}

private string TryGetRUMScriptInternal(string contentType, string requestPath)
{
if (contentType == null)
{
return null;
Expand Down Expand Up @@ -332,19 +357,13 @@ public Stream TryGetStreamInjector(Stream stream, Encoding encoding, string cont
// Once the transaction name is used for RUM it must be frozen
transaction.CandidateTransactionName.Freeze(TransactionNameFreezeReason.AutoBrowserScriptInjection);
var script = _browserMonitoringScriptMaker.GetScript(transaction, null);
if (script == null)
{
return null;
}

return new BrowserMonitoringStreamInjector(() => script, stream, encoding);
return script;
}
catch (Exception ex)
{
Log.Error(ex, "RUM: Failed to build Browser Monitoring agent script");
{
return null;
}
return null;
}
}

Expand Down
@@ -0,0 +1,47 @@
// Copyright 2020 New Relic, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

using System;
using System.IO;
using System.Threading.Tasks;
using NewRelic.Agent.Api;
using NewRelic.Core.Logging;

namespace NewRelic.Agent.Core.BrowserMonitoring
{
public static class BrowserScriptInjectionHelper
{
/// <summary>
/// Determine where to inject the RUM script and write the buffer to the base stream.
/// </summary>
/// <param name="buffer">UTF-8 encoded buffer representing the current page</param>
/// <param name="baseStream"></param>
/// <param name="rumBytes"></param>
/// <param name="transaction"></param>
/// <returns></returns>
public static async Task InjectBrowserScriptAsync(byte[] buffer, Stream baseStream, byte[] rumBytes, ITransaction transaction)
{
var index = BrowserScriptInjectionIndexHelper.TryFindInjectionIndex(buffer);

if (index == -1)
{
// not found, can't inject anything
transaction?.LogFinest("Skipping RUM Injection: No suitable location found to inject script.");
await baseStream.WriteAsync(buffer, 0, buffer.Length);
return;
}

transaction?.LogFinest($"Injecting RUM script at byte index {index}.");


// Write everything up to the insertion index
await baseStream.WriteAsync(buffer, 0, index);

// Write the RUM script
await baseStream.WriteAsync(rumBytes, 0, rumBytes.Length);

// Write the rest of the doc, starting after the insertion index
await baseStream.WriteAsync(buffer, index, buffer.Length - index);
}
}
}
@@ -0,0 +1,128 @@
// Copyright 2020 New Relic, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

using System;
using System.Text;
using System.Text.RegularExpressions;

namespace NewRelic.Agent.Core.BrowserMonitoring
{
internal static class BrowserScriptInjectionIndexHelper
{

private static readonly Regex XUaCompatibleFilter = new Regex(@"(<\s*meta[^>]+http-equiv[\s]*=[\s]*['""]x-ua-compatible['""][^>]*>)", RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase);
private static readonly Regex CharsetFilter = new Regex(@"(<\s*meta[^>]+charset\s*=[^>]*>)", RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase);

/// <summary>
/// Returns the index into the (UTF-8 encoded) buffer where the RUM script should be injected, or -1 if no suitable location is found
/// </summary>
/// <param name="content"></param>
/// <returns></returns>
/// <remarks>
/// Specification for Javascript insertion: https://newrelic.atlassian.net/wiki/spaces/eng/pages/50299103/BAM+Agent+Auto-Instrumentation
/// </remarks>
public static int TryFindInjectionIndex(byte[] content)
{
var contentAsString = Encoding.UTF8.GetString(content);

var openingHeadTagIndex = FindFirstOpeningHeadTag(contentAsString);

// No <HEAD> tag. Attempt to insert before <BODY> tag (not a great fallback option).
if (openingHeadTagIndex == -1)
{
return FindIndexBeforeBodyTag(content, contentAsString);
}

// Since we have a head tag (top of 'page'), search for <X_UA_COMPATIBLE> and for <CHARSET> tags in Head section
var xUaCompatibleFilterMatch = XUaCompatibleFilter.Match(contentAsString, openingHeadTagIndex);
var charsetFilterMatch = CharsetFilter.Match(contentAsString, openingHeadTagIndex);

// Try to find </HEAD> tag. (It's okay if we don't find it!)
var closingHeadTagIndex = contentAsString.IndexOf("</head>", StringComparison.InvariantCultureIgnoreCase);

// Find which of the two tags occurs latest (if at all) and ensure that at least
// one of the matches occurs prior to the closing head tag
if ((xUaCompatibleFilterMatch.Success || charsetFilterMatch.Success) &&
(xUaCompatibleFilterMatch.Index < closingHeadTagIndex || charsetFilterMatch.Index < closingHeadTagIndex))
{
var match = charsetFilterMatch;
if (xUaCompatibleFilterMatch.Index > charsetFilterMatch.Index)
{
match = xUaCompatibleFilterMatch;
}

// find the index just after the end of the regex match in the UTF-8 buffer
var contentSubString = contentAsString.Substring(match.Index, match.Length);
var utf8HeadMatchIndex = IndexOfByteArray(content, contentSubString, out var substringBytesLength);

return utf8HeadMatchIndex + substringBytesLength;
}

// found opening head tag but no meta tags, insert immediately after the opening head tag
// Find first '>' after the opening head tag, which will be end of head opening tag.
var indexOfEndHeadOpeningTag = contentAsString.IndexOf('>', openingHeadTagIndex);

// The <HEAD> tag may be malformed or simply be another type of tag, if so do not use it
if (!(indexOfEndHeadOpeningTag > openingHeadTagIndex))
return -1;

// Get the whole open HEAD tag string
var headOpeningTag = contentAsString.Substring(openingHeadTagIndex, (indexOfEndHeadOpeningTag - openingHeadTagIndex) + 1);
var utf8HeadOpeningTagIndex = IndexOfByteArray(content, headOpeningTag, out var headOpeningTagBytesLength);
return utf8HeadOpeningTagIndex + headOpeningTagBytesLength;
}

private static int FindIndexBeforeBodyTag(byte[] content, string contentAsString)
{
const string bodyOpenTag = "<body";

var indexOfBodyTag = contentAsString.IndexOf(bodyOpenTag, StringComparison.InvariantCultureIgnoreCase);
if (indexOfBodyTag < 0)
return -1;

// find the body tag start index in the UTF-8 buffer
var bodyFromContent = contentAsString.Substring(indexOfBodyTag, bodyOpenTag.Length);
var utf8BodyTagIndex = IndexOfByteArray(content, bodyFromContent, out _);
return utf8BodyTagIndex;
}

private static int FindFirstOpeningHeadTag(string content)
{
int indexOpeningHead = -1;

var indexTemp = content.IndexOf("<head", StringComparison.InvariantCultureIgnoreCase);
if (indexTemp < 0)
return -1;

if (content[indexTemp + 5] == '>' || content[indexTemp + 5] == ' ')
{
indexOpeningHead = indexTemp;
}

return indexOpeningHead;
}

/// <summary>
/// Returns an index into a byte array to find a string in the byte array.
/// Exact match using the encoding provided or UTF-8 by default.
/// </summary>
/// <param name="buffer"></param>
/// <param name="stringToFind"></param>
/// <param name="stringToFindBytesLength"></param>
/// <param name="encoding"></param>
/// <returns></returns>
private static int IndexOfByteArray(byte[] buffer, string stringToFind, out int stringToFindBytesLength, Encoding encoding = null)
{
stringToFindBytesLength = 0;
encoding ??= Encoding.UTF8;

if (buffer.Length == 0 || string.IsNullOrEmpty(stringToFind))
return -1;

var stringToFindBytes = encoding.GetBytes(stringToFind);
stringToFindBytesLength = stringToFindBytes.Length;

return buffer.AsSpan().IndexOf(stringToFindBytes);
}
}
}
6 changes: 6 additions & 0 deletions src/Agent/NewRelic/Agent/Core/Utilities/ExtensionsLoader.cs
Expand Up @@ -37,6 +37,12 @@ public static void Initialize(string installPathExtensionsDirectory)
{ "GenericHostWebHostBuilderExtensionsWrapper", Path.Combine(_installPathExtensionsDirectory, "NewRelic.Providers.Wrapper.AspNetCore.dll") },
{ "NewRelic.Providers.Wrapper.AspNetCore.InvokeActionMethodAsync", Path.Combine(_installPathExtensionsDirectory, "NewRelic.Providers.Wrapper.AspNetCore.dll") },

{ "BuildCommonServicesWrapper6Plus", Path.Combine(_installPathExtensionsDirectory, "NewRelic.Providers.Wrapper.AspNetCore6Plus.dll") },
{ "GenericHostWebHostBuilderExtensionsWrapper6Plus", Path.Combine(_installPathExtensionsDirectory, "NewRelic.Providers.Wrapper.AspNetCore6Plus.dll") },
{ "InvokeActionMethodAsyncWrapper6Plus", Path.Combine(_installPathExtensionsDirectory, "NewRelic.Providers.Wrapper.AspNetCore6Plus.dll") },
{ "ResponseCompressionBodyOnWriteWrapper", Path.Combine(_installPathExtensionsDirectory, "NewRelic.Providers.Wrapper.AspNetCore6Plus.dll") },
{ "PageActionInvokeHandlerAsyncWrapper6Plus", Path.Combine(_installPathExtensionsDirectory, "NewRelic.Providers.Wrapper.AspNetCore6Plus.dll") },

{ "ResolveAppWrapper", Path.Combine(_installPathExtensionsDirectory, "NewRelic.Providers.Wrapper.Owin.dll") },

{ "AspNet.CreateEventExecutionStepsTracer", Path.Combine(_installPathExtensionsDirectory, "NewRelic.Providers.Wrapper.AspNet.dll") },
Expand Down
Expand Up @@ -9,6 +9,7 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;

namespace NewRelic.Agent.Api
{
Expand Down Expand Up @@ -103,6 +104,19 @@ public interface IAgent : IAgentExperimental
/// <param name="requestPath">The path of the request</param>
Stream TryGetStreamInjector(Stream stream, Encoding encoding, string contentType, string requestPath);

/// <summary>
/// Used by AspNetCore6Plus, injects the RUM script if various conditions are met. Assumes (perhaps boldly) that the
/// page content is UTF-8 encoded.
///
/// This method should be called as late as possible (i.e. just before the stream is read) to ensure that the metadata passed in (encoding, contentType, etc) is no longer volatile.
/// </summary>
/// <param name="contentType">The type of content in the stream.</param>
/// <param name="requestPath">The path of the request</param>
/// <param name="buffer">A UTF-8 encoded buffer of the content for this request</param>
/// <param name="baseStream">The stream into which the script (and buffer) should be injected</param>
/// <returns></returns>
Task TryInjectBrowserScriptAsync(string contentType, string requestPath, byte[] buffer, Stream baseStream);

/// <summary>
/// Returns the Trace Metadata of the currently executing transaction.
/// </summary>
Expand Down
Expand Up @@ -37,6 +37,7 @@ public enum WebTransactionType
ASP,
MVC,
WCF,
Razor,
WebAPI,
WebService,
MonoRail,
Expand Down

0 comments on commit 14c6bb1

Please sign in to comment.