Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Azure Monitor Exporter - Add Connection String #14621

Merged
merged 12 commits into from
Aug 28, 2020
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace OpenTelemetry.Exporter.AzureMonitor
{
public class AzureMonitorTraceExporter : ActivityExporter
{
private readonly AzureMonitorTransmitter AzureMonitorTransmitter;
internal readonly AzureMonitorTransmitter AzureMonitorTransmitter;
TimothyMothra marked this conversation as resolved.
Show resolved Hide resolved

public AzureMonitorTraceExporter(AzureMonitorExporterOptions options)
{
Expand Down
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,15 +9,18 @@
using System.Threading;
using System.Threading.Tasks;
using Azure.Core.Pipeline;

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

namespace OpenTelemetry.Exporter.AzureMonitor
{
internal class AzureMonitorTransmitter
{
private readonly ServiceRestClient serviceRestClient;
internal readonly ServiceRestClient serviceRestClient;
private readonly AzureMonitorExporterOptions options;
internal readonly string ikey;

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.ikey, 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.ikey;
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,124 @@
// 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>
public static void GetValues(string connectionString, out string ikey, 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);
ikey = connString.GetInstrumentationKey();
ingestionEndpoint = connString.GetIngestionEndpoint();
}
catch (Exception ex)
{
AzureMonitorTraceExporterEventSource.Log.ConnectionStringError(ex);
throw new Exception("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);
TimothyMothra marked this conversation as resolved.
Show resolved Hide resolved

// 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 + "."),
TimothyMothra marked this conversation as resolved.
Show resolved Hide resolved
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,58 @@
// 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}" });

Assert.AreEqual(testIkey, exporter.AzureMonitorTransmitter.ikey);

// TODO: WHO OWNS THE SWAGGER? CAN WE CHANGE "endpoint" TO BE internal PLEASE?
FieldInfo field = typeof(ServiceRestClient).GetField("endpoint", BindingFlags.Instance | BindingFlags.NonPublic);
var serviceRestClientEndpoint = field.GetValue(exporter.AzureMonitorTransmitter.serviceRestClient);
Assert.AreEqual(testEndpoint, serviceRestClientEndpoint);
}

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

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

Assert.AreEqual(testIkey, exporter.AzureMonitorTransmitter.ikey);

// TODO: WHO OWNS THE SWAGGER? CAN WE CHANGE "endpoint" TO BE internal PLEASE?
FieldInfo field = typeof(ServiceRestClient).GetField("endpoint", BindingFlags.Instance | BindingFlags.NonPublic);
var serviceRestClientEndpoint = field.GetValue(exporter.AzureMonitorTransmitter.serviceRestClient);
Assert.AreEqual(ConnectionString.Constants.DefaultIngestionEndpoint, serviceRestClientEndpoint);
}

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

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

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