Skip to content

Commit

Permalink
Azure Monitor Exporter - Add Connection String (#14621)
Browse files Browse the repository at this point in the history
  • Loading branch information
TimothyMothra authored Aug 28, 2020
1 parent f2800cc commit 258a303
Show file tree
Hide file tree
Showing 10 changed files with 609 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,22 @@ public void SdkVersionCreateFailed(Exception ex)
}
}

[NonEvent]
public void ConnectionStringError(Exception ex)
{
if (this.IsEnabled(EventLevel.Error, EventKeywords.All))
{
this.ConnectionStringError(ex.ToInvariantString());
}
}

[Event(1, Message = "{0}", Level = EventLevel.Warning)]
public void WarnToParseConfigurationString(string message) => this.WriteEvent(1, message);

[Event(2, Message = "Error creating SdkVersion : '{0}'", Level = EventLevel.Warning)]
public void WarnSdkVersionCreateException(string message) => this.WriteEvent(2, message);

[Event(3, Message = "Connection String Error: '{0}'", Level = EventLevel.Error)]
public void ConnectionStringError(string message) => this.WriteEvent(3, message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
using System.Threading;
using System.Threading.Tasks;
using Azure.Core.Pipeline;

using OpenTelemetry.Exporter.AzureMonitor.ConnectionString;
using OpenTelemetry.Exporter.AzureMonitor.Models;
using OpenTelemetry.Trace;

Expand All @@ -18,6 +20,7 @@ internal class AzureMonitorTransmitter
{
private readonly ServiceRestClient serviceRestClient;
private readonly AzureMonitorExporterOptions options;
private readonly string instrumentationKey;

private static readonly IReadOnlyDictionary<TelemetryType, string> Telemetry_Base_Type_Mapping = new Dictionary<TelemetryType, string>
{
Expand All @@ -37,8 +40,10 @@ internal class AzureMonitorTransmitter

public AzureMonitorTransmitter(AzureMonitorExporterOptions exporterOptions)
{
ConnectionStringParser.GetValues(exporterOptions.ConnectionString, out this.instrumentationKey, out string ingestionEndpoint);

options = exporterOptions;
serviceRestClient = new ServiceRestClient(new ClientDiagnostics(options), HttpPipelineBuilder.Build(options));
serviceRestClient = new ServiceRestClient(new ClientDiagnostics(options), HttpPipelineBuilder.Build(options), endpoint: ingestionEndpoint);
}

internal async ValueTask<int> AddBatchActivityAsync(IEnumerable<Activity> batchActivity, CancellationToken cancellationToken)
Expand All @@ -54,6 +59,7 @@ internal async ValueTask<int> AddBatchActivityAsync(IEnumerable<Activity> batchA
foreach (var activity in batchActivity)
{
telemetryItem = GeneratePartAEnvelope(activity);
telemetryItem.IKey = this.instrumentationKey;
telemetryItem.Data = GenerateTelemetryData(activity);
telemetryItems.Add(telemetryItem);
}
Expand All @@ -67,8 +73,6 @@ private static TelemetryEnvelope GeneratePartAEnvelope(Activity activity)
{
// TODO: Get TelemetryEnvelope name changed in swagger
TelemetryEnvelope envelope = new TelemetryEnvelope(PartA_Name_Mapping[activity.GetTelemetryType()], activity.StartTimeUtc);
// TODO: Extract IKey from connectionstring
envelope.IKey = "IKey";
// TODO: Validate if Azure SDK has common function to generate role instance
envelope.Tags[ContextTagKeys.AiCloudRoleInstance.ToString()] = "testRoleInstance";

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Linq;

namespace OpenTelemetry.Exporter.AzureMonitor.ConnectionString
{
internal static class ConnectionStringParser
{
/// <summary>
/// Parse a connection string that matches the format: "key1=value1;key2=value2;key3=value3".
/// This method will encapsulate all exception handling.
/// </summary>
/// <remarks>
/// Official Doc: <a href="https://docs.microsoft.com/azure/azure-monitor/app/sdk-connection-string" />.
/// </remarks>
/// <exception cref="InvalidOperationException">
/// Any exceptions that occur while parsing the connection string will be wrapped and re-thrown.
/// </exception>
public static void GetValues(string connectionString, out string instrumentationKey, out string ingestionEndpoint)
{
try
{
if (connectionString == null)
{
throw new ArgumentNullException(nameof(connectionString));
}
else if (connectionString.Length > Constants.ConnectionStringMaxLength)
{
throw new ArgumentOutOfRangeException(nameof(connectionString), $"Values greater than {Constants.ConnectionStringMaxLength} characters are not allowed.");
}

var connString = Azure.Core.ConnectionString.Parse(connectionString);
instrumentationKey = connString.GetInstrumentationKey();
ingestionEndpoint = connString.GetIngestionEndpoint();
}
catch (Exception ex)
{
AzureMonitorTraceExporterEventSource.Log.ConnectionStringError(ex);
throw new InvalidOperationException("Connection String Error: " + ex.Message, ex);
}
}

internal static string GetInstrumentationKey(this Azure.Core.ConnectionString connectionString) => connectionString.GetRequired(Constants.InstrumentationKeyKey);

/// <summary>
/// Evaluate connection string and return the requested endpoint.
/// </summary>
/// <remarks>
/// Parsing the connection string MUST follow these rules:
/// 1. check for explicit endpoint (location is ignored)
/// 2. check for endpoint suffix (location is optional)
/// 3. use default endpoint (location is ignored)
/// This behavior is required by the Connection String Specification.
/// </remarks>
internal static string GetIngestionEndpoint(this Azure.Core.ConnectionString connectionString)
{
// Passing the user input values through the Uri constructor will verify that we've built a valid endpoint.
Uri uri;

if (connectionString.TryGetNonRequiredValue(Constants.IngestionExplicitEndpointKey, out string explicitEndpoint))
{
if (!Uri.TryCreate(explicitEndpoint, UriKind.Absolute, out uri))
{
throw new ArgumentException($"The value for {Constants.IngestionExplicitEndpointKey} is invalid. '{explicitEndpoint}'");
}
}
else if (connectionString.TryGetNonRequiredValue(Constants.EndpointSuffixKey, out string endpointSuffix))
{
var location = connectionString.GetNonRequired(Constants.LocationKey);
if (!TryBuildUri(prefix: Constants.IngestionPrefix, suffix: endpointSuffix, location: location, uri: out uri))
{
throw new ArgumentException($"The value for {Constants.EndpointSuffixKey} is invalid. '{endpointSuffix}'");
}
}
else
{
return Constants.DefaultIngestionEndpoint;
}

return uri.AbsoluteUri;
}

/// <summary>
/// Construct a Uri from the possible parts.
/// Format: "location.prefix.suffix".
/// Example: "https://westus2.dc.applicationinsights.azure.cn/".
/// </summary>
/// <remarks>
/// Will also attempt to sanitize user input. Won't fail if the user typo-ed an extra period.
/// </remarks>
internal static bool TryBuildUri(string prefix, string suffix, out Uri uri, string location = null)
{
// Location and Suffix are user input fields and need to be sanitized (extra spaces or periods).
char[] trimPeriod = new char[] { '.' };

if (location != null)
{
location = location.Trim().TrimEnd(trimPeriod);

// Location names are expected to match Azure region names. No special characters allowed.
if (!location.All(x => char.IsLetterOrDigit(x)))
{
throw new ArgumentException($"The value for Location must contain only alphanumeric characters. '{location}'");
}
}

var uriString = string.Concat("https://",
string.IsNullOrEmpty(location) ? string.Empty : (location + "."),
prefix,
".",
suffix.Trim().TrimStart(trimPeriod));

return Uri.TryCreate(uriString, UriKind.Absolute, out uri);
}

/// <summary>
/// This method wraps <see cref="Azure.Core.ConnectionString.GetNonRequired(string)"/> in a null check.
/// </summary>
internal static bool TryGetNonRequiredValue(this Azure.Core.ConnectionString connectionString, string key, out string value)
{
value = connectionString.GetNonRequired(key);
return value != null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

namespace OpenTelemetry.Exporter.AzureMonitor.ConnectionString
{
internal static class Constants
{
/// <summary>
/// Default endpoint for Ingestion (aka Breeze).
/// </summary>
internal const string DefaultIngestionEndpoint = "https://dc.services.visualstudio.com/";

/// <summary>
/// Sub-domain for Ingestion endpoint (aka Breeze). (https://dc.applicationinsights.azure.com/).
/// </summary>
internal const string IngestionPrefix = "dc";

/// <summary>
/// This is the key that a customer would use to specify an explicit endpoint in the connection string.
/// </summary>
internal const string IngestionExplicitEndpointKey = "IngestionEndpoint";

/// <summary>
/// This is the key that a customer would use to specify an instrumentation key in the connection string.
/// </summary>
internal const string InstrumentationKeyKey = "InstrumentationKey";

/// <summary>
/// This is the key that a customer would use to specify an endpoint suffix in the connection string.
/// </summary>
internal const string EndpointSuffixKey = "EndpointSuffix";

/// <summary>
/// This is the key that a customer would use to specify a location in the connection string.
/// </summary>
internal const string LocationKey = "Location";

/// <summary>
/// Maximum allowed length for connection string.
/// </summary>
/// <remarks>
/// Currently 8 accepted keywords (~200 characters).
/// Assuming 200 characters per value (~1600 characters).
/// Total theoretical max length: (1600 + 200) = 1800.
/// Setting an over-exaggerated max length to protect against malicious injections (2^12 = 4096).
/// </remarks>
internal const int ConnectionStringMaxLength = 4096;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<ItemGroup>
<Compile Include="$(AzureCoreSharedSources)ArrayBufferWriter.cs" Link="Shared\%(RecursiveDir)\%(Filename)%(Extension)" />
<Compile Include="$(AzureCoreSharedSources)ClientDiagnostics.cs" Link="Shared\%(RecursiveDir)\%(Filename)%(Extension)" />
<Compile Include="$(AzureCoreSharedSources)ConnectionString.cs" Link="Shared\%(RecursiveDir)\%(Filename)%(Extension)" />
<Compile Include="$(AzureCoreSharedSources)ContentTypeUtilities.cs" Link="Shared\%(RecursiveDir)\%(Filename)%(Extension)" />
<Compile Include="$(AzureCoreSharedSources)DiagnosticScope.cs" Link="Shared\%(RecursiveDir)\%(Filename)%(Extension)" />
<Compile Include="$(AzureCoreSharedSources)DiagnosticScopeFactory.cs" Link="Shared\%(RecursiveDir)\%(Filename)%(Extension)" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Reflection;

using NUnit.Framework;

namespace OpenTelemetry.Exporter.AzureMonitor
{
public class AzureMonitorTraceExporterTests
{
[Test]
public void VerifyConnectionString_CorrectlySetsEndpoint()
{
var testIkey = "test_ikey";
var testEndpoint = "https://www.bing.com/";

var exporter = new AzureMonitorTraceExporter(new AzureMonitorExporterOptions { ConnectionString = $"InstrumentationKey={testIkey};IngestionEndpoint={testEndpoint}" });

GetInternalFields(exporter, out string ikey, out string endpoint);
Assert.AreEqual(testIkey, ikey);
Assert.AreEqual(testEndpoint, endpoint);
}

[Test]
public void VerifyConnectionString_CorrectlySetsDefaultEndpoint()
{
var testIkey = "test_ikey";

var exporter = new AzureMonitorTraceExporter(new AzureMonitorExporterOptions { ConnectionString = $"InstrumentationKey={testIkey};" });

GetInternalFields(exporter, out string ikey, out string endpoint);
Assert.AreEqual(testIkey, ikey);
Assert.AreEqual(ConnectionString.Constants.DefaultIngestionEndpoint, endpoint);
}

[Test]
public void VerifyConnectionString_ThrowsExceptionWhenInvalid()
{
Assert.Throws<InvalidOperationException>(() => new AzureMonitorTraceExporter(new AzureMonitorExporterOptions { ConnectionString = null }));
}

[Test]
public void VerifyConnectionString_ThrowsExceptionWhenMissingInstrumentationKey()
{
var testEndpoint = "https://www.bing.com/";

Assert.Throws<InvalidOperationException>(() => new AzureMonitorTraceExporter(new AzureMonitorExporterOptions { ConnectionString = $"IngestionEndpoint={testEndpoint}" }));
}

private void GetInternalFields(AzureMonitorTraceExporter exporter, out string ikey, out string endpoint)
{
// TODO: NEED A BETTER APPROACH FOR TESTING. WE DECIDED AGAINST MAKING FIELDS "internal".
// instrumentationKey: AzureMonitorTraceExporter.AzureMonitorTransmitter.instrumentationKey
// endpoint: AzureMonitorTraceExporter.AzureMonitorTransmitter.ServiceRestClient.endpoint

var transmitter = typeof(AzureMonitorTraceExporter)
.GetField("AzureMonitorTransmitter", BindingFlags.Instance | BindingFlags.NonPublic)
.GetValue(exporter);

ikey = typeof(AzureMonitorTransmitter)
.GetField("instrumentationKey", BindingFlags.Instance | BindingFlags.NonPublic)
.GetValue(transmitter)
.ToString();

var serviceRestClient = typeof(AzureMonitorTransmitter)
.GetField("serviceRestClient", BindingFlags.Instance | BindingFlags.NonPublic)
.GetValue(transmitter);

endpoint = typeof(ServiceRestClient)
.GetField("endpoint", BindingFlags.Instance | BindingFlags.NonPublic)
.GetValue(serviceRestClient)
.ToString();
}
}
}
Loading

0 comments on commit 258a303

Please sign in to comment.