diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6b0ab4808f..7bfe538c29 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -41,6 +41,7 @@ body: - OpenTelemetry.Instrumentation.GrpcNetClient - OpenTelemetry.Instrumentation.Hangfire - OpenTelemetry.Instrumentation.Http + - OpenTelemetry.Instrumentation.Kusto - OpenTelemetry.Instrumentation.MassTransit - OpenTelemetry.Instrumentation.MySqlData - OpenTelemetry.Instrumentation.Owin diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index cbd7ef881d..15d10ba698 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -41,6 +41,7 @@ body: - OpenTelemetry.Instrumentation.GrpcNetClient - OpenTelemetry.Instrumentation.Hangfire - OpenTelemetry.Instrumentation.Http + - OpenTelemetry.Instrumentation.Kusto - OpenTelemetry.Instrumentation.MassTransit - OpenTelemetry.Instrumentation.MySqlData - OpenTelemetry.Instrumentation.Owin diff --git a/.github/ISSUE_TEMPLATE/release_request.yml b/.github/ISSUE_TEMPLATE/release_request.yml index 93ec597d8c..8b85b74924 100644 --- a/.github/ISSUE_TEMPLATE/release_request.yml +++ b/.github/ISSUE_TEMPLATE/release_request.yml @@ -38,6 +38,7 @@ body: - OpenTelemetry.Instrumentation.GrpcNetClient - OpenTelemetry.Instrumentation.Hangfire - OpenTelemetry.Instrumentation.Http + - OpenTelemetry.Instrumentation.Kusto - OpenTelemetry.Instrumentation.MassTransit - OpenTelemetry.Instrumentation.MySqlData - OpenTelemetry.Instrumentation.Owin diff --git a/.github/codecov.yml b/.github/codecov.yml index cb1a79a8e7..5961f03c26 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -135,6 +135,11 @@ flags: paths: - src/OpenTelemetry.Instrumentation.Http + unittests-Instrumentation.Kusto: + carryforward: true + paths: + - src/OpenTelemetry.Instrumentation.Kusto + unittests-Instrumentation.Owin: carryforward: true paths: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f94c12c0c..ce35b24311 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,7 @@ jobs: instrumentation-grpcnetclient: ['*/OpenTelemetry.Instrumentation.GrpcNetClient*/**', '!**/*.md'] instrumentation-hangfire: ['*/OpenTelemetry.Instrumentation.Hangfire*/**', '!**/*.md'] instrumentation-http: ['*/OpenTelemetry.Instrumentation.Http*/**', '!**/*.md'] + instrumentation-kusto: ['*/OpenTelemetry.Instrumentation.Kusto*/**', '!**/*.md'] instrumentation-owin: ['*/OpenTelemetry.Instrumentation.Owin*/**', 'examples/owin/**', '!**/*.md'] instrumentation-process: ['*/OpenTelemetry.Instrumentation.Process*/**', 'examples/process-instrumentation/**', '!**/*.md'] instrumentation-quartz: ['*/OpenTelemetry.Instrumentation.Quartz*/**', '!**/*.md'] @@ -363,6 +364,17 @@ jobs: project-name: Component[OpenTelemetry.Instrumentation.Http] code-cov-name: Instrumentation.Http + build-test-instrumentation-kusto: + needs: detect-changes + if: | + contains(needs.detect-changes.outputs.changes, 'instrumentation-kusto') + || contains(needs.detect-changes.outputs.changes, 'build') + || contains(needs.detect-changes.outputs.changes, 'shared') + uses: ./.github/workflows/Component.BuildTest.yml + with: + project-name: Component[OpenTelemetry.Instrumentation.Kusto] + code-cov-name: Instrumentation.Kusto + build-test-instrumentation-owin: needs: detect-changes if: | diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 95c3b93b1e..4ccca50a26 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -29,6 +29,7 @@ on: - OpenTelemetry.Instrumentation.GrpcNetClient - OpenTelemetry.Instrumentation.Hangfire - OpenTelemetry.Instrumentation.Http + - OpenTelemetry.Instrumentation.Kusto - OpenTelemetry.Instrumentation.MassTransit - OpenTelemetry.Instrumentation.MySqlData - OpenTelemetry.Instrumentation.Owin diff --git a/.gitignore b/.gitignore index a742e5530f..2814f1c368 100644 --- a/.gitignore +++ b/.gitignore @@ -446,3 +446,7 @@ test/**/BenchmarkResults/** !test/**/BenchmarkResults/results/ # Do NOT ignore files ending with -report-github.md anywhere under BenchmarkResults !test/**/BenchmarkResults/results/*-report-github.md + +# Ignore Verify received files +*.received.* +*.received/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 14909e109e..8a27cd0632 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,6 +83,8 @@ + + @@ -117,12 +119,14 @@ + + @@ -137,9 +141,11 @@ + + diff --git a/opentelemetry-dotnet-contrib.slnx b/opentelemetry-dotnet-contrib.slnx index b43576cdd9..ae15e37ea6 100644 --- a/opentelemetry-dotnet-contrib.slnx +++ b/opentelemetry-dotnet-contrib.slnx @@ -157,6 +157,7 @@ + @@ -267,6 +268,8 @@ + + diff --git a/src/OpenTelemetry.Instrumentation.Kusto/.publicApi/PublicAPI.Shipped.txt b/src/OpenTelemetry.Instrumentation.Kusto/.publicApi/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/.publicApi/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/OpenTelemetry.Instrumentation.Kusto/.publicApi/PublicAPI.Unshipped.txt b/src/OpenTelemetry.Instrumentation.Kusto/.publicApi/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..370806f093 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/.publicApi/PublicAPI.Unshipped.txt @@ -0,0 +1,21 @@ +#nullable enable +OpenTelemetry.Metrics.KustoMeterInstrumentationOptions +OpenTelemetry.Metrics.KustoMeterInstrumentationOptions.KustoMeterInstrumentationOptions() -> void +OpenTelemetry.Metrics.KustoMeterInstrumentationOptions.RecordQuerySummary.get -> bool +OpenTelemetry.Metrics.KustoMeterInstrumentationOptions.RecordQuerySummary.set -> void +OpenTelemetry.Metrics.KustoMeterInstrumentationOptions.RecordQueryText.get -> bool +OpenTelemetry.Metrics.KustoMeterInstrumentationOptions.RecordQueryText.set -> void +OpenTelemetry.Metrics.MeterProviderBuilderExtensions +OpenTelemetry.Trace.KustoTraceInstrumentationOptions +OpenTelemetry.Trace.KustoTraceInstrumentationOptions.Enrich.get -> System.Action? +OpenTelemetry.Trace.KustoTraceInstrumentationOptions.Enrich.set -> void +OpenTelemetry.Trace.KustoTraceInstrumentationOptions.KustoTraceInstrumentationOptions() -> void +OpenTelemetry.Trace.KustoTraceInstrumentationOptions.RecordQuerySummary.get -> bool +OpenTelemetry.Trace.KustoTraceInstrumentationOptions.RecordQuerySummary.set -> void +OpenTelemetry.Trace.KustoTraceInstrumentationOptions.RecordQueryText.get -> bool +OpenTelemetry.Trace.KustoTraceInstrumentationOptions.RecordQueryText.set -> void +OpenTelemetry.Trace.TracerProviderBuilderExtensions +static OpenTelemetry.Metrics.MeterProviderBuilderExtensions.AddKustoInstrumentation(this OpenTelemetry.Metrics.MeterProviderBuilder! builder) -> OpenTelemetry.Metrics.MeterProviderBuilder! +static OpenTelemetry.Metrics.MeterProviderBuilderExtensions.AddKustoInstrumentation(this OpenTelemetry.Metrics.MeterProviderBuilder! builder, System.Action? configureKustoMeterInstrumentationOptions) -> OpenTelemetry.Metrics.MeterProviderBuilder! +static OpenTelemetry.Trace.TracerProviderBuilderExtensions.AddKustoInstrumentation(this OpenTelemetry.Trace.TracerProviderBuilder! builder) -> OpenTelemetry.Trace.TracerProviderBuilder! +static OpenTelemetry.Trace.TracerProviderBuilderExtensions.AddKustoInstrumentation(this OpenTelemetry.Trace.TracerProviderBuilder! builder, System.Action? configureKustoTraceInstrumentationOptions) -> OpenTelemetry.Trace.TracerProviderBuilder! diff --git a/src/OpenTelemetry.Instrumentation.Kusto/CHANGELOG.md b/src/OpenTelemetry.Instrumentation.Kusto/CHANGELOG.md new file mode 100644 index 0000000000..6d34ec650a --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +* Initial implementation. diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/InstrumentationHandleManagerExtensions.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/InstrumentationHandleManagerExtensions.cs new file mode 100644 index 0000000000..4a70716fef --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/InstrumentationHandleManagerExtensions.cs @@ -0,0 +1,28 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +/// +/// Provides extension methods for . +/// +internal static class InstrumentationHandleManagerExtensions +{ + /// + /// Returns if tracing is active (i.e., there is at least one tracing handle); otherwise, . + /// + /// + /// The to check for active tracing handles. + /// + /// if tracing is active; otherwise, . + public static bool IsTracingActive(this InstrumentationHandleManager handleManager) => handleManager.TracingHandles > 0; + + /// + /// Returns if metrics is active (i.e., there is at least one metrics handle); otherwise, . + /// + /// + /// The to check for active metrics handles. + /// + /// if metrics is active; otherwise, . + public static bool IsMetricsActive(this InstrumentationHandleManager handleManager) => handleManager.MetricHandles > 0; +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoActivitySourceHelper.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoActivitySourceHelper.cs new file mode 100644 index 0000000000..b1c6cdb0e2 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoActivitySourceHelper.cs @@ -0,0 +1,34 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +/// +/// Helper class to hold common properties used by Kusto instrumentation. +/// +internal static class KustoActivitySourceHelper +{ + public const string DbSystem = "azure.kusto"; + public const string ClientRequestIdTagKey = $"{DbSystem}.client_request_id"; + + public static readonly Assembly Assembly = typeof(KustoActivitySourceHelper).Assembly; + public static readonly AssemblyName AssemblyName = Assembly.GetName(); + public static readonly string PackageVersion = Assembly.GetPackageVersion(); + + public static readonly string ActivitySourceName = AssemblyName.Name!; + public static readonly ActivitySource ActivitySource = new(ActivitySourceName, PackageVersion); + + public static readonly string MeterName = AssemblyName.Name!; + public static readonly Meter Meter = new(MeterName, PackageVersion); + + public static readonly Histogram OperationDurationHistogram = Meter.CreateHistogram( + "db.client.operation.duration", + unit: "s", + advice: new InstrumentAdvice() { HistogramBucketBoundaries = [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10] }, + description: "Duration of database client operations"); +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoInstrumentation.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoInstrumentation.cs new file mode 100644 index 0000000000..002b01519b --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoInstrumentation.cs @@ -0,0 +1,44 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Kusto.Cloud.Platform.Utils; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +/// +/// Class to hold the singleton instances used for Kusto instrumentation. +/// +internal static class KustoInstrumentation +{ + private static readonly Lazy Listener = new(() => + { + Environment.SetEnvironmentVariable("KUSTO_DATA_TRACE_REQUEST_BODY", "1"); + + var listener = new KustoTraceRecordListener(); + TraceSourceManager.AddTraceListener(listener, startupDone: true); + + return listener; + }); + + /// + /// Gets or sets the post-configured trace options for Kusto instrumentation. + /// + public static KustoTraceInstrumentationOptions TraceOptions { get; set; } = new KustoTraceInstrumentationOptions(); + + /// + /// Gets or sets the post-configured meter options for Kusto instrumentation. + /// + public static KustoMeterInstrumentationOptions MeterOptions { get; set; } = new KustoMeterInstrumentationOptions(); + + /// + /// Gets the that tracks if there are any active listeners for . + /// + public static InstrumentationHandleManager HandleManager { get; } = new InstrumentationHandleManager(); + + /// + /// Initializes the Kusto instrumentation by ensuring the listener is created and registered with the client library. + /// + public static void Initialize() => _ = Listener.Value; +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoInstrumentationEventSource.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoInstrumentationEventSource.cs new file mode 100644 index 0000000000..dc36473da8 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoInstrumentationEventSource.cs @@ -0,0 +1,46 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics.Tracing; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +/// +/// EventSource for Kusto instrumentation. +/// +[EventSource(Name = "OpenTelemetry-Instrumentation-Kusto")] +internal sealed class KustoInstrumentationEventSource : EventSource +{ + public static KustoInstrumentationEventSource Log { get; } = new(); + + [NonEvent] + public void EnrichmentException(Exception ex) + { + if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.EnrichmentException(ex.ToInvariantString()); + } + } + + [Event(1, Message = "Enrichment exception: {0}", Level = EventLevel.Error)] + public void EnrichmentException(string exception) => this.WriteEvent(1, exception); + + [Event(2, Message = "Trace record payload is NULL or has NULL message, record will not be processed.", Level = EventLevel.Warning)] + public void NullPayload() => this.WriteEvent(2); + + [Event(3, Message = "Failed to find context for activity ID '{0}', operation data will not be recorded.", Level = EventLevel.Warning)] + public void ContextNotFound(string activityId) => this.WriteEvent(3, activityId); + + [NonEvent] + public void UnknownErrorProcessingTraceRecord(Exception ex) + { + if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.UnknownErrorProcessingTraceRecord(ex.ToInvariantString()); + } + } + + [Event(4, Message = "Unknown error processing trace record, Exception: {0}", Level = EventLevel.Error)] + public void UnknownErrorProcessingTraceRecord(string exception) => this.WriteEvent(4, exception); +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoProcessor.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoProcessor.cs new file mode 100644 index 0000000000..1599369529 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoProcessor.cs @@ -0,0 +1,252 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Kusto.Language; +using Kusto.Language.Editor; +using Kusto.Language.Symbols; +using Kusto.Language.Syntax; + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +/// +/// Use the Kusto query language services to process Kusto queries for summarization and sanitization. +/// +internal static class KustoProcessor +{ + // Because we're not doing full semantic analysis for queries, we can reuse the default global state (which includes all built-in functions and types) + private static readonly GlobalState KustoParserGlobalState = GlobalState.Default.WithCache(); + + private enum ReplacementKind + { + Placeholder, + Remove, + } + + /// + /// Processes the specified Kusto query and optionally generates a summary and/or a sanitized version based on the + /// provided options. + /// + /// + /// If both summarization and sanitization are requested, the query is parsed only once for + /// efficiency. The returned will have null values for summary or sanitized output + /// if the corresponding option is not enabled. + /// + /// + /// Indicates whether to generate a summary of the query. If , the returned object will + /// include a summarized representation. + /// + /// + /// Indicates whether to generate a sanitized version of the query. If , the returned object + /// will include a sanitized representation. + /// + /// + /// The Kusto query to process. + /// + /// + /// A containing the summary and/or sanitized version of the query, depending on + /// the options specified. + /// + public static KustoStatementInfo Process(bool shouldSummarize, bool shouldSanitize, string query) + { + string? summarized = null; + string? sanitized = null; + + KustoCode? code = null; + + // Note that order matters here as summarization requires semantic analysis to find potential table references, + // but we want to avoid parsing twice if both are requested. + if (shouldSummarize) + { + code ??= KustoCode.ParseAndAnalyze(query, KustoParserGlobalState); + summarized = Summarize(code); + } + + if (shouldSanitize) + { + code ??= KustoCode.Parse(query, KustoParserGlobalState); + sanitized = Sanitize(code); + } + + return new KustoStatementInfo(summarized, sanitized); + } + + private static string Sanitize(KustoCode code) + { + // Collect nodes that need replacements + var collector = new SanitizerVisitor(); + code.Syntax.Accept(collector); + + if (!collector.ShouldSanitize) + { + return code.Text; + } + + // Apply edits to text + var edits = collector.Edits; + var text = new EditString(code.Text); + if (edits.Count == 0 || !text.CanApplyAll(edits)) + { + return code.Text; + } + + var newText = text.ApplyAll(edits); + return newText; + } + + private static string Summarize(KustoCode code) + { + using var walker = new SummarizerVisitor(); + code.Syntax.Accept(walker); + return walker.GetSummary(); + } + + private static TextEdit CreatePlaceholder(SyntaxElement node) => TextEdit.Replacement(node.TextStart, node.Width, "?"); + + private static TextEdit CreateRemoval(SyntaxElement node) => TextEdit.Deletion(node.TextStart, node.Width); + + /// + /// Visitor that traverses the KQL looking for literal values to replace with the PLACEHOLDER value. + /// + private sealed class SanitizerVisitor : DefaultSyntaxVisitor + { + private readonly List edits = []; + + public IReadOnlyList Edits => this.edits; + + /// + /// Gets a value indicating whether the query should be sanitized. + /// + /// + /// If the query is parameterized, we should skip sanitization. + /// https://opentelemetry.io/docs/specs/semconv/database/database-spans/#sanitization-of-dbquerytext. + /// + public bool ShouldSanitize { get; private set; } = true; + + public override void VisitLiteralExpression(LiteralExpression node) => this.edits.Add(CreatePlaceholder(node)); + + public override void VisitDynamicExpression(DynamicExpression node) => this.edits.Add(CreatePlaceholder(node)); + + public override void VisitPrefixUnaryExpression(PrefixUnaryExpression node) + { + this.edits.Add(CreateRemoval(node.Operator)); + base.VisitPrefixUnaryExpression(node); + } + + public override void VisitQueryParametersStatement(QueryParametersStatement node) => this.ShouldSanitize = false; + + protected override void DefaultVisit(SyntaxNode node) => this.VisitChildren(node); + + private void VisitChildren(SyntaxNode node) + { + if (node != null) + { + for (int i = 0; i < node.ChildCount; i++) + { + if (node.GetChild(i) is SyntaxNode child) + { + child.Accept(this); + } + } + } + } + } + + /// + /// Visitor that traverses the KQL to produce a summarized representation of the query. + /// + private sealed class SummarizerVisitor : DefaultSyntaxVisitor, IDisposable + { + private readonly TruncatingStringBuilder builder = new(); + + public override void VisitPipeExpression(PipeExpression node) + { + node.Expression.Accept(this); + this.builder.Append(node.Bar.Text); + this.builder.Append(' '); + node.Operator.Accept(this); + } + + public override void VisitNameReference(NameReference node) + { + if (node.ResultType is TableSymbol ts) + { + this.builder.Append(ts.Name); + this.builder.Append(' '); + } + else if (node.ResultType is ErrorSymbol) + { + this.builder.Append(node.ToString(IncludeTrivia.SingleLine)); + this.builder.Append(' '); + } + } + + public override void VisitFunctionCallExpression(FunctionCallExpression node) + { + if (node.Name.SimpleName == "materialized_view") + { + this.builder.Append(node.ToString(IncludeTrivia.SingleLine)); + this.builder.Append(' '); + } + } + + public override void VisitDataTableExpression(DataTableExpression node) + { + this.builder.Append(node.DataTableKeyword.Text); + this.builder.Append(' '); + } + + public override void VisitCustomCommand(CustomCommand node) + { + this.builder.Append(node.DotToken.Text); + this.builder.Append(node.Custom.GetFirstToken().Text); + this.builder.Append(' '); + } + + public string GetSummary() + { + this.builder.TrimEnd(); + return this.builder.ToString(); + } + + public void Dispose() => this.builder.Dispose(); + + protected override void DefaultVisit(SyntaxNode node) + { + if (node is QueryOperator qo) + { + this.VisitQueryOperator(qo); + } + else + { + this.VisitChildren(node); + } + } + + private void VisitQueryOperator(QueryOperator node) + { + if (node is BadQueryOperator) + { + return; + } + + this.builder.Append(node.GetFirstToken().ToString(IncludeTrivia.SingleLine)); + this.builder.Append(' '); + + this.VisitChildren(node); + } + + private void VisitChildren(SyntaxNode node) + { + if (node != null) + { + for (int i = 0; i < node.ChildCount; i++) + { + if (node.GetChild(i) is SyntaxNode child) + { + child.Accept(this); + } + } + } + } + } +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoStatementInfo.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoStatementInfo.cs new file mode 100644 index 0000000000..b4f83b691b --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoStatementInfo.cs @@ -0,0 +1,6 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +internal readonly record struct KustoStatementInfo(string? Summarized, string? Sanitized); diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoTraceRecordListener.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoTraceRecordListener.cs new file mode 100644 index 0000000000..c6c49119b0 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/KustoTraceRecordListener.cs @@ -0,0 +1,267 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Concurrent; +using System.Diagnostics; +using OpenTelemetry.Trace; +using KustoUtils = Kusto.Cloud.Platform.Utils; + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +/// +/// Class that is registered with the Kusto client library to receive trace records. +/// +/// +/// The Kusto client library uses its own tracing infrastructure. Many types share names with common diagnostic types +/// (e.g. Activity, ITraceListener, etc.) but in the Kusto.Cloud.Platform.Utils namespace. +/// +internal sealed class KustoTraceRecordListener : KustoUtils.ITraceListener +{ + // The client's async machinery may not call us back using the same AsyncLocal context, so we must manually track + // the Activity's ActivityId (which the client guarantees will be unique) with the context data we need. + private readonly ConcurrentDictionary contexts = new(); + + public override string Name { get; } = nameof(KustoTraceRecordListener); + + public override bool IsThreadSafe => true; + + public override void Flush() + { + } + + public override void Write(KustoUtils.TraceRecord record) + { + if (record?.Message is null) + { + KustoInstrumentationEventSource.Log.NullPayload(); + return; + } + + if (!KustoInstrumentation.HandleManager.IsTracingActive() && !KustoInstrumentation.HandleManager.IsMetricsActive()) + { + return; + } + + try + { + if (record.IsRequestStart()) + { + this.HandleHttpRequestStart(record); + } + else if (record.IsActivityComplete()) + { + this.HandleActivityComplete(record); + } + else if (record.IsException()) + { + this.HandleException(record); + } + } + catch (Exception ex) + { + KustoInstrumentationEventSource.Log.UnknownErrorProcessingTraceRecord(ex); + } + } + + private static double GetElapsedTime(long begin) + { +#if NET + var duration = Stopwatch.GetElapsedTime(begin); +#else + var end = Stopwatch.GetTimestamp(); + var timestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; + var delta = end - begin; + var ticks = (long)(timestampToTicks * delta); + var duration = new TimeSpan(ticks); +#endif + + return duration.TotalSeconds; + } + + private static bool ShouldComputeTags(Activity? activity) => + (activity is not null && activity.IsAllDataRequested) || KustoInstrumentation.HandleManager.IsMetricsActive(); + + private void CallEnrichment(KustoUtils.TraceRecord record) + { + try + { + var activity = this.GetContext(record)?.Activity; + if (activity is not null && activity.IsAllDataRequested) + { + KustoInstrumentation.TraceOptions.Enrich?.Invoke(activity, record); + } + } + catch (Exception ex) + { + KustoInstrumentationEventSource.Log.EnrichmentException(ex); + } + } + + private void HandleException(KustoUtils.TraceRecord record) + { + var context = this.GetContext(record); + var activity = context?.Activity; + if (context is null) + { + return; + } + + var result = TraceRecordParser.ParseException(record.Message.AsSpan()); + if (!result.ErrorType.IsEmpty) + { + activity?.SetTag(SemanticConventions.AttributeErrorType, result.ErrorType.ToString()); + context.Value.MeterTags.Add(SemanticConventions.AttributeErrorType, result.ErrorType.ToString()); + } + + var description = result.ErrorMessage.IsEmpty ? null : result.ErrorMessage.ToString(); + activity?.SetStatus(ActivityStatusCode.Error, description); + + this.CallEnrichment(record); + } + + private void HandleHttpRequestStart(KustoUtils.TraceRecord record) + { + var beginTimestamp = Stopwatch.GetTimestamp(); + var operationName = record.Activity.ActivityType; + + var activity = KustoActivitySourceHelper.ActivitySource.StartActivity(operationName, ActivityKind.Client); + var meterTags = default(TagList); + + if (ShouldComputeTags(activity)) + { + activity?.DisplayName = operationName; + activity?.AddTag(KustoActivitySourceHelper.ClientRequestIdTagKey, record.Activity.ClientRequestId.ToString()); + + activity?.AddTag(SemanticConventions.AttributeDbSystemName, KustoActivitySourceHelper.DbSystem); + activity?.AddTag(SemanticConventions.AttributeDbOperationName, operationName); + meterTags.Add(SemanticConventions.AttributeDbSystemName, KustoActivitySourceHelper.DbSystem); + meterTags.Add(SemanticConventions.AttributeDbOperationName, operationName); + + var result = TraceRecordParser.ParseRequestStart(record.Message.AsSpan()); + + if (!string.IsNullOrEmpty(result.ServerAddress)) + { + activity?.AddTag(SemanticConventions.AttributeServerAddress, result.ServerAddress); + meterTags.Add(SemanticConventions.AttributeServerAddress, result.ServerAddress); + } + + if (result.ServerPort is not null) + { + activity?.AddTag(SemanticConventions.AttributeServerPort, result.ServerPort.Value); + meterTags.Add(SemanticConventions.AttributeServerPort, result.ServerPort.Value); + } + + if (!result.Database.IsEmpty) + { + activity?.AddTag(SemanticConventions.AttributeDbNamespace, result.Database.ToString()); + meterTags.Add(SemanticConventions.AttributeDbNamespace, result.Database.ToString()); + } + + if (!result.QueryText.IsEmpty) + { + var shouldSummarize = KustoInstrumentation.TraceOptions.RecordQuerySummary || KustoInstrumentation.MeterOptions.RecordQuerySummary; + var shouldSanitize = KustoInstrumentation.TraceOptions.RecordQueryText || KustoInstrumentation.MeterOptions.RecordQueryText; + var info = KustoProcessor.Process(shouldSummarize, shouldSanitize, result.QueryText.ToString()); + + if (!string.IsNullOrEmpty(info.Sanitized)) + { + if (KustoInstrumentation.TraceOptions.RecordQueryText) + { + activity?.AddTag(SemanticConventions.AttributeDbQueryText, info.Sanitized); + } + + if (KustoInstrumentation.MeterOptions.RecordQueryText) + { + meterTags.Add(SemanticConventions.AttributeDbQueryText, info.Sanitized); + } + } + + if (!string.IsNullOrEmpty(info.Summarized)) + { + if (KustoInstrumentation.TraceOptions.RecordQuerySummary) + { + activity?.AddTag(SemanticConventions.AttributeDbQuerySummary, info.Summarized); + activity?.DisplayName = info.Summarized!; + } + + if (KustoInstrumentation.MeterOptions.RecordQuerySummary) + { + meterTags.Add(SemanticConventions.AttributeDbQuerySummary, info.Summarized); + } + } + } + } + + this.contexts[record.Activity.ActivityId] = new ContextData(beginTimestamp, meterTags, activity!); + + this.CallEnrichment(record); + } + + private void HandleActivityComplete(KustoUtils.TraceRecord record) + { + var context = this.GetContext(record); + if (context is null) + { + return; + } + + var activity = context.Value.Activity; + + var result = TraceRecordParser.ParseActivityComplete(record.Message.AsSpan()); + if (result.HowEnded.Equals("Success".AsSpan(), StringComparison.Ordinal)) + { + activity?.SetStatus(ActivityStatusCode.Ok); + } + + this.CallEnrichment(record); + activity?.Stop(); + + var duration = activity?.Duration.TotalSeconds ?? GetElapsedTime(context.Value.BeginTimestamp); + KustoActivitySourceHelper.OperationDurationHistogram.Record(duration, context.Value.MeterTags); + + this.contexts.TryRemove(record.Activity.ActivityId, out _); + } + + private ContextData? GetContext(KustoUtils.TraceRecord record) + { + if (this.contexts.TryGetValue(record.Activity.ActivityId, out var context)) + { + return context; + } + + KustoInstrumentationEventSource.Log.ContextNotFound(record.Activity.ActivityId.ToString()); + return null; + } + + /// + /// Holds context data for an ongoing operation. + /// + private readonly struct ContextData + { + public ContextData(long beginTimestamp, TagList meterTags, Activity activity) + { + this.BeginTimestamp = beginTimestamp; + this.MeterTags = meterTags; + this.Activity = activity; + } + + /// + /// Gets the timestamp when the operation began. Used to compute duration if the + /// is not available (i.e. in a metrics-only scenario). + /// + public long BeginTimestamp { get; } + + /// + /// Gets the collection of tags associated with the operation that should be applies to metrics. + /// + public TagList MeterTags { get; } + + /// + /// Gets the current activity associated with the instance, if any. + /// + /// + /// Will be in a metrics-only scenario. + /// + public Activity? Activity { get; } + } +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/SpanExtensions.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/SpanExtensions.cs new file mode 100644 index 0000000000..1f1cdc9969 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/SpanExtensions.cs @@ -0,0 +1,28 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +/// +/// Extension methods for . +/// +internal static class SpanExtensions +{ + /// + /// Slices the span after the first occurrence of the . + /// + /// + /// The span to slice. + /// + /// + /// The value to search for. + /// + /// + /// A that is a slice of the original span after the first occurrence of the . + /// + public static ReadOnlySpan SliceAfter(this ReadOnlySpan span, ReadOnlySpan needle) + { + var idx = span.IndexOf(needle); + return idx >= 0 ? span.Slice(idx + needle.Length) : []; + } +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/TraceRecordExtensions.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/TraceRecordExtensions.cs new file mode 100644 index 0000000000..ab7dbbdfa2 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/TraceRecordExtensions.cs @@ -0,0 +1,24 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using KustoUtils = Kusto.Cloud.Platform.Utils; + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +internal static class TraceRecordExtensions +{ + public static bool IsRequestStart(this KustoUtils.TraceRecord record) + { + return record.Message.StartsWith("$$HTTPREQUEST[", StringComparison.Ordinal); + } + + public static bool IsException(this KustoUtils.TraceRecord record) + { + return record.TraceSourceName == "KD.Exceptions"; + } + + public static bool IsActivityComplete(this KustoUtils.TraceRecord record) + { + return record.Message.StartsWith("MonitoredActivityCompleted", StringComparison.Ordinal); + } +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/TraceRecordParser.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/TraceRecordParser.cs new file mode 100644 index 0000000000..b9ba03a477 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/TraceRecordParser.cs @@ -0,0 +1,100 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET9_0_OR_GREATER +using System.Buffers; +#endif + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +/// +/// Class that parses the delimited messages in instances. +/// +internal class TraceRecordParser +{ +#if NET9_0_OR_GREATER + private static readonly SearchValues Delimiters = SearchValues.Create([',', '\n']); +#else + private static readonly char[] Delimiters = [',', '\n']; +#endif + + public static ParsedRequestStart ParseRequestStart(ReadOnlySpan message) + { + var uri = ExtractValueBetween(message, "Uri=").ToString(); + Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsed); + var database = ExtractValueBetween(message, "DatabaseName="); + + // Query text may have embedded delimiters, however it is always the last field in the message + // so we can just take everything after "text=" + var queryText = message.SliceAfter("text="); + + return new ParsedRequestStart(parsed?.Host, parsed?.Port, database, queryText); + } + + public static ParsedActivityComplete ParseActivityComplete(ReadOnlySpan message) + { + var howEnded = ExtractValueBetween(message, "HowEnded="); + return new ParsedActivityComplete(howEnded); + } + + public static ParsedException ParseException(ReadOnlySpan message) + { + var errorMessage = ExtractValueBetween(message, "ErrorMessage="); + var errorType = ExtractValueBetween(message, "Exception object created: "); + return new ParsedException(errorMessage, errorType); + } + + private static ReadOnlySpan ExtractValueBetween(ReadOnlySpan haystack, ReadOnlySpan needle) + { + var remaining = haystack.SliceAfter(needle); + + var endIndex = remaining.IndexOfAny(Delimiters); + if (endIndex < 0) + { + endIndex = remaining.Length; + } + + var result = remaining.Slice(0, endIndex); + result = result.Trim(); // Trim to specifically handle newlines, which may be multiple characters + + return result; + } + + internal readonly ref struct ParsedRequestStart + { + public readonly string? ServerAddress; + public readonly int? ServerPort; + public readonly ReadOnlySpan Database; + public readonly ReadOnlySpan QueryText; + + public ParsedRequestStart(string? serverAddress, int? serverPort, ReadOnlySpan database, ReadOnlySpan queryText) + { + this.ServerAddress = serverAddress; + this.ServerPort = serverPort; + this.Database = database; + this.QueryText = queryText; + } + } + + internal readonly ref struct ParsedActivityComplete + { + public readonly ReadOnlySpan HowEnded; + + public ParsedActivityComplete(ReadOnlySpan howEnded) + { + this.HowEnded = howEnded; + } + } + + internal readonly ref struct ParsedException + { + public readonly ReadOnlySpan ErrorMessage; + public readonly ReadOnlySpan ErrorType; + + public ParsedException(ReadOnlySpan errorMessage, ReadOnlySpan errorType) + { + this.ErrorMessage = errorMessage; + this.ErrorType = errorType; + } + } +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/Implementation/TruncatingStringBuilder.cs b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/TruncatingStringBuilder.cs new file mode 100644 index 0000000000..20783cc6f8 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/Implementation/TruncatingStringBuilder.cs @@ -0,0 +1,104 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Buffers; + +namespace OpenTelemetry.Instrumentation.Kusto.Implementation; + +/// +/// A high-performance string builder that uses ArrayPool and truncates at a maximum length. +/// Once truncation occurs, all future append operations are ignored. +/// +internal sealed class TruncatingStringBuilder : IDisposable +{ + private const int MaxLength = 255; + private char[]? buffer; + private int position; + private bool isTruncated; + + public TruncatingStringBuilder() + { + this.buffer = ArrayPool.Shared.Rent(MaxLength); + this.position = 0; + this.isTruncated = false; + } + + public int Length => this.position; + + public bool IsTruncated => this.isTruncated; + + public void Append(string value) + { + if (this.isTruncated || string.IsNullOrEmpty(value)) + { + return; + } + + this.Append(value.AsSpan()); + } + + public void Append(ReadOnlySpan value) + { + if (this.isTruncated || value.IsEmpty || this.buffer == null) + { + return; + } + + if (this.position + value.Length > MaxLength) + { + this.isTruncated = true; + return; + } + + value.CopyTo(this.buffer.AsSpan(this.position)); + this.position += value.Length; + } + + public void Append(char value) + { + if (this.isTruncated || this.buffer == null) + { + return; + } + + if (this.position + 1 > MaxLength) + { + this.isTruncated = true; + return; + } + + this.buffer[this.position++] = value; + } + + public void TrimEnd() + { + if (this.buffer == null || this.position == 0) + { + return; + } + + while (this.position > 0 && char.IsWhiteSpace(this.buffer[this.position - 1])) + { + this.position--; + } + } + + public override string ToString() + { + if (this.buffer == null || this.position == 0) + { + return string.Empty; + } + + return new string(this.buffer, 0, this.position); + } + + public void Dispose() + { + if (this.buffer != null) + { + ArrayPool.Shared.Return(this.buffer); + this.buffer = null; + } + } +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/KustoMeterInstrumentationOptions.cs b/src/OpenTelemetry.Instrumentation.Kusto/KustoMeterInstrumentationOptions.cs new file mode 100644 index 0000000000..675024e35f --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/KustoMeterInstrumentationOptions.cs @@ -0,0 +1,22 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.Metrics; + +/// +/// Options for Kusto meter instrumentation. +/// +public sealed class KustoMeterInstrumentationOptions +{ + /// + /// Gets or sets a value indicating whether the query text should be recorded as an attribute on the activity. + /// Default is . + /// + public bool RecordQueryText { get; set; } + + /// + /// Gets or sets a value indicating whether a summary of the query should be recorded as an attribute on the activity. + /// Default is . + /// + public bool RecordQuerySummary { get; set; } = true; +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/KustoTraceInstrumentationOptions.cs b/src/OpenTelemetry.Instrumentation.Kusto/KustoTraceInstrumentationOptions.cs new file mode 100644 index 0000000000..a8b4b7c557 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/KustoTraceInstrumentationOptions.cs @@ -0,0 +1,30 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using KustoUtils = Kusto.Cloud.Platform.Utils; + +namespace OpenTelemetry.Trace; + +/// +/// Options for Kusto trace instrumentation. +/// +public sealed class KustoTraceInstrumentationOptions +{ + /// + /// Gets or sets a value indicating whether the query text should be recorded as an attribute on the activity. + /// Default is . + /// + public bool RecordQueryText { get; set; } + + /// + /// Gets or sets a value indicating whether a summary of the query should be recorded as an attribute on the activity. + /// Default is . + /// + public bool RecordQuerySummary { get; set; } = true; + + /// + /// Gets or sets an action to enrich the Activity with additional information from the TraceRecord. + /// + public Action? Enrich { get; set; } +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/MeterProviderBuilderExtensions.cs b/src/OpenTelemetry.Instrumentation.Kusto/MeterProviderBuilderExtensions.cs new file mode 100644 index 0000000000..05bdb82734 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/MeterProviderBuilderExtensions.cs @@ -0,0 +1,52 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenTelemetry.Instrumentation.Kusto.Implementation; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Metrics; + +/// +/// Extension methods to simplify registering of Kusto instrumentation. +/// +public static class MeterProviderBuilderExtensions +{ + /// + /// Enables Kusto instrumentation. + /// + /// being configured. + /// The instance of to chain the calls. + public static MeterProviderBuilder AddKustoInstrumentation(this MeterProviderBuilder builder) => + builder.AddKustoInstrumentation(configureKustoMeterInstrumentationOptions: null); + + /// + /// Enables Kusto instrumentation. + /// + /// being configured. + /// Callback action for configuring . + /// The instance of to chain the calls. + public static MeterProviderBuilder AddKustoInstrumentation(this MeterProviderBuilder builder, Action? configureKustoMeterInstrumentationOptions) + { + Guard.ThrowIfNull(builder); + + if (configureKustoMeterInstrumentationOptions != null) + { + builder.ConfigureServices(services => services.Configure(configureKustoMeterInstrumentationOptions)); + } + + // Be sure to eagerly initialize the instrumentation, as we must set environment variables before any clients are created. + KustoInstrumentation.Initialize(); + + builder.AddInstrumentation(sp => + { + KustoInstrumentation.MeterOptions = sp.GetRequiredService>().CurrentValue; + return KustoInstrumentation.HandleManager.AddMetricHandle(); + }); + + builder.AddMeter(KustoActivitySourceHelper.MeterName); + + return builder; + } +} diff --git a/src/OpenTelemetry.Instrumentation.Kusto/OpenTelemetry.Instrumentation.Kusto.csproj b/src/OpenTelemetry.Instrumentation.Kusto/OpenTelemetry.Instrumentation.Kusto.csproj new file mode 100644 index 0000000000..8fb946605d --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/OpenTelemetry.Instrumentation.Kusto.csproj @@ -0,0 +1,41 @@ + + + + $(TargetFrameworksForLibraries) + $(TargetFrameworks);$(NetFrameworkMinimumSupportedVersion) + OpenTelemetry Kusto Instrumentation. + $(PackageTags);Kusto;AzureDataExplorer + Instrumentation.Kusto- + + false + + + + + + + + + + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenTelemetry.Instrumentation.Kusto/README.md b/src/OpenTelemetry.Instrumentation.Kusto/README.md new file mode 100644 index 0000000000..f34516a357 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/README.md @@ -0,0 +1,216 @@ +# Kusto Instrumentation for OpenTelemetry + +| Status | | +| ----------- | ------------------------------ | +| Stability | [Alpha](../../README.md#alpha) | + +[![NuGet version badge](https://img.shields.io/nuget/v/OpenTelemetry.Instrumentation.Kusto)](https://www.nuget.org/packages/OpenTelemetry.Instrumentation.Kusto) +[![NuGet download count badge](https://img.shields.io/nuget/dt/OpenTelemetry.Instrumentation.Kusto)](https://www.nuget.org/packages/OpenTelemetry.Instrumentation.Kusto) +[![codecov.io](https://codecov.io/gh/open-telemetry/opentelemetry-dotnet-contrib/branch/main/graphs/badge.svg?flag=unittests-Instrumentation.Kusto)](https://app.codecov.io/gh/open-telemetry/opentelemetry-dotnet-contrib?flags[0]=unittests-Instrumentation.Kusto) + +This is an +[Instrumentation Library](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/glossary.md#instrumentation-library), +which instruments Azure Data Explorer (Kusto) client libraries +and collects telemetry about Kusto operations. + +## Steps to enable OpenTelemetry.Instrumentation.Kusto + +### Step 1: Install Package + +Add a reference to the +[`OpenTelemetry.Instrumentation.Kusto`](https://www.nuget.org/packages/OpenTelemetry.Instrumentation.Kusto) +package. Also, add any other instrumentations & exporters you will need. + +```shell +dotnet add package OpenTelemetry.Instrumentation.Kusto +``` + +### Step 2: Enable Kusto Instrumentation at application startup + +Kusto instrumentation must be enabled at application startup. + +#### Traces + +The following example demonstrates adding Kusto traces instrumentation +to a console application. This example also sets up the OpenTelemetry Console +exporter, which requires adding the package +[`OpenTelemetry.Exporter.Console`](https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/src/OpenTelemetry.Exporter.Console/README.md) +to the application. + +```csharp +using OpenTelemetry.Trace; + +public class Program +{ + public static void Main(string[] args) + { + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddKustoInstrumentation() + .AddConsoleExporter() + .Build(); + } +} +``` + +#### Metrics + +The following example demonstrates adding Kusto metrics instrumentation +to a console application. This example also sets up the OpenTelemetry Console +exporter, which requires adding the package +[`OpenTelemetry.Exporter.Console`](https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/src/OpenTelemetry.Exporter.Console/README.md) +to the application. + +```csharp +using OpenTelemetry.Metrics; + +public class Program +{ + public static void Main(string[] args) + { + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddKustoInstrumentation() + .AddConsoleExporter() + .Build(); + } +} +``` + +##### List of metrics produced + +The instrumentation is implemented based on [metrics semantic +conventions](https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/database/database-metrics.md). +Currently, the instrumentation supports the following metric: + +| Name | Instrument Type | Unit | Description | Attributes | +|--------------------------------|-----------------|------|-----------------------------------------|----------------------------------------------------------------------------------------------------------------------------| +| `db.client.operation.duration` | Histogram | `s` | Duration of database client operations. | `db.system`, `db.operation.name`, `db.namespace`, `db.query.summary` (1), `server.address`, `server.port`, `error.type`(2) | + +1 `db.query.summary` is only included when `RecordQuerySummary` is enabled +2 `error.type` is only included when an error occurs + +## Advanced configuration + +This instrumentation can be configured to change the default behavior by using +`KustoInstrumentationOptions`. + +### RecordQueryText + +This option can be set to instruct the instrumentation to record the sanitized +query text as an attribute on the activity. Query text is +sanitized to remove literal values and replace them with a placeholder character. + +The default value is `false` and can be changed by the code like below. + +```csharp +using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddKustoInstrumentation( + options => options.RecordQueryText = true) + .AddConsoleExporter() + .Build(); +``` + +### RecordQuerySummary + +This option can be set to instruct the instrumentation to record a query +summary as an attribute on the activity. The query summary +is automatically generated from the query text and contains the operation type +and relevant object names. + +The default value is `true` and can be changed by the code like below. + +```csharp +using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddKustoInstrumentation( + options => options.RecordQuerySummary = false) + .AddConsoleExporter() + .Build(); +``` + +### Enrich + +This option can be used to enrich the activity with additional information from +the raw `TraceRecord` object. The `Enrich` action is called only when +`activity.IsAllDataRequested` is `true`. It contains the activity itself (which +can be enriched) and the actual `TraceRecord` from the Kusto client library. + +The following code snippet shows how to add additional tags using `Enrich`. + +```csharp +using OpenTelemetry.Instrumentation.Kusto.Implementation; + +using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddKustoInstrumentation(opt => opt.Enrich = (activity, record) => + { + // Add custom tags based on the TraceRecord + activity.SetTag("azure.kusto.activity_id", record.Activity.ActivityId); + activity.SetTag("azure.kusto.activity_type", record.Activity.ActivityType); + }) + .Build(); +``` + +[Processor](https://github.com/open-telemetry/opentelemetry-dotnet/tree/main/docs/trace/extending-the-sdk/README.md#processor), +is the general extensibility point to add additional properties to any activity. +The `Enrich` option is specific to this instrumentation, and is provided to get +access to the `TraceRecord` object. + +#### Custom Query Summarization + +The `Enrich` callback can be used to implement custom query summarization logic. +For example, you can extract summary information from query comments: + +```csharp +using OpenTelemetry.Instrumentation.Kusto.Implementation; + +using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddKustoInstrumentation(opt => + { + // Disable automatic summarization + opt.RecordQuerySummary = false; + + // Extract custom summary from query comments + opt.Enrich = (activity, record) => + { + const string key = "// otel-custom-summary="; + var message = record.Message.AsSpan(); + var begin = message.IndexOf(key, StringComparison.Ordinal); + + if (begin < 0) + { + return; + } + + var summary = message.Slice(begin + key.Length); + var end = summary.IndexOfAny('\r', '\n'); + if (end < 0) + { + end = summary.Length; + } + + summary = summary.Slice(0, end).Trim(); + var summaryString = summary.ToString(); + + activity.SetTag("db.query.summary", summaryString); + activity.DisplayName = summaryString; + }; + }) + .Build(); +``` + +With this configuration, a query like: + +```kql +// otel-custom-summary=Get active users +Users +| where IsActive == true +| take 100 +``` + +Would result in an activity with the summary and display name +set to `"Get active users"`. + +## References + +* [OpenTelemetry Project](https://opentelemetry.io/) +* [Azure Data Explorer (Kusto)](https://docs.microsoft.com/azure/data-explorer/) +* [OpenTelemetry semantic conventions for database spans](https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/database/database-spans.md) +* [OpenTelemetry semantic conventions for database metrics](https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/database/database-metrics.md) diff --git a/src/OpenTelemetry.Instrumentation.Kusto/TracerProviderBuilderExtensions.cs b/src/OpenTelemetry.Instrumentation.Kusto/TracerProviderBuilderExtensions.cs new file mode 100644 index 0000000000..0dd2b57aa0 --- /dev/null +++ b/src/OpenTelemetry.Instrumentation.Kusto/TracerProviderBuilderExtensions.cs @@ -0,0 +1,54 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenTelemetry.Instrumentation.Kusto.Implementation; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Trace; + +/// +/// Extension methods to simplify registering of Kusto instrumentation. +/// +public static class TracerProviderBuilderExtensions +{ + /// + /// Enables Kusto instrumentation. + /// + /// being configured. + /// The instance of to chain the calls. + public static TracerProviderBuilder AddKustoInstrumentation(this TracerProviderBuilder builder) => + AddKustoInstrumentation(builder, configureKustoTraceInstrumentationOptions: null); + + /// + /// Enables Kusto instrumentation. + /// + /// being configured. + /// Callback action for configuring . + /// The instance of to chain the calls. + public static TracerProviderBuilder AddKustoInstrumentation( + this TracerProviderBuilder builder, + Action? configureKustoTraceInstrumentationOptions) + { + Guard.ThrowIfNull(builder); + + if (configureKustoTraceInstrumentationOptions != null) + { + builder.ConfigureServices(services => services.Configure(configureKustoTraceInstrumentationOptions)); + } + + // Be sure to eagerly initialize the instrumentation, as we must set environment variables before any clients are created. + KustoInstrumentation.Initialize(); + + builder.AddInstrumentation(sp => + { + KustoInstrumentation.TraceOptions = sp.GetRequiredService>().CurrentValue; + return KustoInstrumentation.HandleManager.AddTracingHandle(); + }); + + builder.AddSource(KustoActivitySourceHelper.ActivitySourceName); + + return builder; + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/InstrumentationBenchmarks.cs b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/InstrumentationBenchmarks.cs new file mode 100644 index 0000000000..d86b690381 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/InstrumentationBenchmarks.cs @@ -0,0 +1,160 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using BenchmarkDotNet.Attributes; +using OpenTelemetry.Instrumentation.Kusto.Implementation; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using KustoUtils = Kusto.Cloud.Platform.Utils; + +namespace OpenTelemetry.Instrumentation.Kusto.Benchmarks; + +/// +/// Benchmarks that simulate end-to-end trace and metrics instrumentation by manually creating TraceRecords +/// and passing them through both the trace listener and metrics listener. +/// +[MemoryDiagnoser] +public class InstrumentationBenchmarks +{ + private readonly Guid activityId = Guid.NewGuid(); + private readonly string clientRequestId = "SW52YWxpZFRhYmxlIHwgdGFrZSAxMA=="; + + private KustoTraceRecordListener? listener; + private KustoUtils.TraceRecord requestStartRecord = null!; + private KustoUtils.TraceRecord activityCompleteRecord = null!; + private KustoUtils.TraceRecord exceptionRecord = null!; + private TracerProvider? tracerProvider; + private MeterProvider? meterProvider; + private IDisposable? tracingHandle; + private IDisposable? metricHandle; + + [GlobalSetup] + public void Setup() + { + // Setup TracerProvider with the Kusto activity source + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource(KustoActivitySourceHelper.ActivitySourceName) + .Build(); + + // Setup MeterProvider with the Kusto meter + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter(KustoActivitySourceHelper.MeterName) + .Build(); + + // Activate instrumentation handles + this.tracingHandle = KustoInstrumentation.HandleManager.AddTracingHandle(); + this.metricHandle = KustoInstrumentation.HandleManager.AddMetricHandle(); + + // Create single listener for both traces and metrics + this.listener = new KustoTraceRecordListener(); + + // Create TraceRecord instances that simulate a query execution flow + this.requestStartRecord = CreateRequestStartRecord( + this.activityId, + this.clientRequestId, + "StormEvents | take 10 | where Col1 = 7 | summarize by Date, Time"); + + this.activityCompleteRecord = CreateActivityCompleteRecord( + this.activityId, + this.clientRequestId); + + this.exceptionRecord = CreateExceptionRecord( + this.activityId, + this.clientRequestId); + } + + [GlobalCleanup] + public void Cleanup() + { + this.tracingHandle?.Dispose(); + this.metricHandle?.Dispose(); + this.tracerProvider?.Dispose(); + this.meterProvider?.Dispose(); + } + + [Benchmark] + public void SuccessfulQuery() + { + // Simulate a successful query execution + this.listener!.Write(this.requestStartRecord); + this.listener.Write(this.activityCompleteRecord); + } + + [Benchmark] + public void FailedQuery() + { + // Simulate a failed query execution + this.listener!.Write(this.requestStartRecord); + this.listener.Write(this.exceptionRecord); + this.listener.Write(this.activityCompleteRecord); + } + + [Benchmark] + public void TraceListenerOnly() + { + // Benchmark just the trace listener (metrics disabled) + this.metricHandle?.Dispose(); + this.listener!.Write(this.requestStartRecord); + this.listener.Write(this.activityCompleteRecord); + this.metricHandle = KustoInstrumentation.HandleManager.AddMetricHandle(); + } + + [Benchmark] + public void MetricListenerOnly() + { + // Benchmark just the metric listener (tracing disabled) + this.tracingHandle?.Dispose(); + this.listener!.Write(this.requestStartRecord); + this.listener.Write(this.activityCompleteRecord); + this.tracingHandle = KustoInstrumentation.HandleManager.AddTracingHandle(); + } + + private static KustoUtils.TraceRecord CreateRequestStartRecord(Guid activityId, string clientRequestId, string queryText) + { + var message = $$"""$$HTTPREQUEST[RestClient2]: Verb=POST, Uri=http://127.0.0.1:49902/v1/rest/query, DatabaseName=NetDefaultDB, App=testhost, User=REDMOND\\benchmarkuser, ClientVersion=Kusto.Dotnet.Client:{14.0.2+b2d66614da1a4ff4561c5037c48e5be7002d66d4}|Runtime:{.NET_10.0.0/CLRv10.0.0/10.0.0-rtm.25523.111}, ClientRequestId={{clientRequestId}}, text={{queryText}}"""; + + var activity = CreateActivity(activityId, clientRequestId); + using var context = KustoUtils.Context.PushActivityContext(activity); + + return KustoUtils.TraceRecord.Create("Kusto.Data", KustoUtils.TraceVerbosity.Verbose, message); + } + + private static KustoUtils.TraceRecord CreateActivityCompleteRecord(Guid activityId, string clientRequestId) + { + const string message = "MonitoredActivityCompletedSuccessfully: TestActivityType=KD.RestClient.ExecuteQuery, Timestamp=2025-12-01T02:30:30.0211167Z, ParentActivityId={0}, Duration=4316.802 [ms], HowEnded=Success"; + + var activity = CreateActivity(activityId, clientRequestId); + using var context = KustoUtils.Context.PushActivityContext(activity); + + return KustoUtils.TraceRecord.Create("Kusto.Data", KustoUtils.TraceVerbosity.Verbose, message); + } + + private static KustoUtils.TraceRecord CreateExceptionRecord(Guid activityId, string clientRequestId) + { + var message = + $""" + Exception object created: Kusto.Data.Exceptions.SemanticException + [0]Kusto.Data.Exceptions.SemanticException: Semantic error: 'take' operator: Failed to resolve table or column expression named 'InvalidTable' + Timestamp=2025-12-01T02:39:36.3878585Z + ClientRequestId={clientRequestId} + ActivityId={activityId} + ActivityType=KD.RestClient.ExecuteQuery + ErrorCode=SEM0100 + ErrorReason=BadRequest + ErrorMessage='take' operator: Failed to resolve table or column expression named 'InvalidTable' + DataSource=http://127.0.0.1:62413/v1/rest/query + DatabaseName=NetDefaultDB + """; + + var activity = CreateActivity(activityId, clientRequestId); + using var context = KustoUtils.Context.PushActivityContext(activity); + + return KustoUtils.TraceRecord.Create("KD.Exceptions", KustoUtils.TraceVerbosity.Error, message); + } + + private static KustoUtils.Activity CreateActivity(Guid activityId, string clientRequestId) + { + var sub = Guid.NewGuid(); + return KustoUtils.Activity.CreateImportActivity(activityId, sub, sub, clientRequestId, "FakeActivity"); + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/KustoProcessorBenchmarks.cs b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/KustoProcessorBenchmarks.cs new file mode 100644 index 0000000000..d42c77c226 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/KustoProcessorBenchmarks.cs @@ -0,0 +1,26 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnosers; +using OpenTelemetry.Instrumentation.Kusto.Implementation; + +namespace OpenTelemetry.Instrumentation.Kusto.Benchmarks; + +[MemoryDiagnoser] +public class KustoProcessorBenchmarks +{ + public string Query { get; set; } = "StormEvents | join kind=inner (PopulationData) on State | project State, EventType, Population"; + + [Benchmark] + public void ProcessSummarizeAndSanitize() => KustoProcessor.Process(shouldSummarize: true, shouldSanitize: true, this.Query); + + [Benchmark] + public void ProcessSummarizeOnly() => KustoProcessor.Process(shouldSummarize: true, shouldSanitize: false, this.Query); + + [Benchmark] + public void ProcessSanitizeOnly() => KustoProcessor.Process(shouldSummarize: false, shouldSanitize: true, this.Query); + + [Benchmark] + public void ProcessNeither() => KustoProcessor.Process(shouldSummarize: false, shouldSanitize: false, this.Query); +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/KustoProcessorProfilingBenchmarks.cs b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/KustoProcessorProfilingBenchmarks.cs new file mode 100644 index 0000000000..854ceaff7f --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/KustoProcessorProfilingBenchmarks.cs @@ -0,0 +1,21 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnosers; +using Microsoft.VSDiagnostics; +using OpenTelemetry.Instrumentation.Kusto.Implementation; + +namespace OpenTelemetry.Instrumentation.Kusto.Benchmarks; + +[ShortRunJob] +[MemoryDiagnoser] +[DotNetObjectAllocDiagnoser] +[DotNetObjectAllocJobConfiguration] +public class KustoProcessorProfilingBenchmarks +{ + public string Query { get; } = "StormEvents | join kind=inner (PopulationData) on State | project State, EventType, Population"; + + [Benchmark] + public void ProcessSummarizeAndSanitize() => KustoProcessor.Process(shouldSummarize: true, shouldSanitize: true, this.Query); +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/OpenTelemetry.Instrumentation.Kusto.Benchmarks.csproj b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/OpenTelemetry.Instrumentation.Kusto.Benchmarks.csproj new file mode 100644 index 0000000000..2da2366090 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/OpenTelemetry.Instrumentation.Kusto.Benchmarks.csproj @@ -0,0 +1,25 @@ + + + + + $(SupportedNetTargets) + Exe + false + true + + + + + + + + + + + + + + $(DefineConstants);DISABLE_PROFILER_AGENT_CONFIG + + + diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/Program.cs b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/Program.cs new file mode 100644 index 0000000000..c1bcf5834b --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/Program.cs @@ -0,0 +1,24 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using BenchmarkDotNet.Running; + +namespace OpenTelemetry.Instrumentation.Kusto.Benchmarks; + +internal class Program +{ + private static void Main(string[] args) + { + if (Debugger.IsAttached) + { + var benchmarks = new InstrumentationBenchmarks(); + benchmarks.Setup(); + benchmarks.FailedQuery(); + } + else + { + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + } + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/README.md b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/README.md new file mode 100644 index 0000000000..2d03f78f69 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Benchmarks/README.md @@ -0,0 +1,63 @@ +# OpenTelemetry.Instrumentation.Kusto.Benchmarks + +This project contains benchmarks for the OpenTelemetry Kusto instrumentation library. + +## Running the Benchmarks + +To run all benchmarks: + +```bash +dotnet run --configuration Release --framework net10.0 --project test\OpenTelemetry.Instrumentation.Kusto.Benchmarks +``` + +Then choose the benchmark class that you want to run by entering the required +option number from the list of options shown on the Console window. + +> [!TIP] +> The Profiling benchmarks are designed to run quickly and use the Visual +> Studio diagnosers to gather performance data. + +## Results + +### Full instrumentation + +```plain + +BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7093) +Intel Core Ultra 7 165H 3.80GHz, 1 CPU, 22 logical and 16 physical cores +.NET SDK 10.0.100 + [Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 + DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 + + +``` + +| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | +|------------------- |----------:|----------:|----------:|-------:|-------:|----------:| +| SuccessfulQuery | 9.043 us | 0.1183 us | 0.1048 us | 0.9308 | 0.0153 | 11.48 KB | +| FailedQuery | 10.076 us | 0.2007 us | 0.3354 us | 0.9613 | 0.0153 | 11.91 KB | +| TraceListenerOnly | 9.411 us | 0.1788 us | 0.2325 us | 0.9308 | 0.0153 | 11.52 KB | +| MetricListenerOnly | 9.352 us | 0.1746 us | 0.2613 us | 0.9308 | 0.0153 | 11.52 KB | + +### Summarization and sanitization processing + +Summarization and sanitization are the most expensive parts of instrumentation, so +there are benchmarks to measure their specific cost. + +```plain + +BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7093) +Intel Core Ultra 7 165H 3.80GHz, 1 CPU, 22 logical and 16 physical cores +.NET SDK 10.0.100-rc.2.25502.107 + [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + DefaultJob : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + + +``` + +| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | +|---------------------------- |----------:|----------:|----------:|-------:|-------:|----------:| +| ProcessSummarizeAndSanitize | 10.141 us | 0.1926 us | 0.2436 us | 1.0834 | 0.0153 | 13.36 KB | +| ProcessSummarizeOnly | 9.549 us | 0.1772 us | 0.1571 us | 1.0071 | 0.0153 | 12.48 KB | +| ProcessSanitizeOnly | 4.154 us | 0.0827 us | 0.1832 us | 0.5798 | 0.0038 | 7.13 KB | +| ProcessNeither | 0.0566 ns | 0.0259 ns | 0.0610 ns | - | - | - | diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/DataReaderExtensions.cs b/test/OpenTelemetry.Instrumentation.Kusto.Tests/DataReaderExtensions.cs new file mode 100644 index 0000000000..132aa4be0d --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/DataReaderExtensions.cs @@ -0,0 +1,16 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Data; + +namespace OpenTelemetry.Instrumentation.Kusto.Tests; + +internal static class DataReaderExtensions +{ + public static void Consume(this IDataReader reader) + { + while (reader.Read()) + { + } + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/DependencyInjectionConfigTests.cs b/test/OpenTelemetry.Instrumentation.Kusto.Tests/DependencyInjectionConfigTests.cs new file mode 100644 index 0000000000..938543f91a --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/DependencyInjectionConfigTests.cs @@ -0,0 +1,169 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Instrumentation.Kusto.Implementation; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Xunit; + +namespace OpenTelemetry.Instrumentation.Kusto.Tests; + +public class DependencyInjectionConfigTests : IDisposable +{ + public DependencyInjectionConfigTests() + { + KustoInstrumentation.TraceOptions = new KustoTraceInstrumentationOptions(); + KustoInstrumentation.MeterOptions = new KustoMeterInstrumentationOptions(); + } + + public void Dispose() + { + KustoInstrumentation.TraceOptions = new KustoTraceInstrumentationOptions(); + KustoInstrumentation.MeterOptions = new KustoMeterInstrumentationOptions(); + } + + [Fact] + public void TestTracingOptionsDiConfig() + { + var enrichCalled = false; + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .ConfigureServices(services => + { + services.Configure(options => + { + options.Enrich = (activity, record) => { enrichCalled = true; }; + }); + }) + .AddKustoInstrumentation(configureKustoTraceInstrumentationOptions: null) + .Build(); + + // Assert that the options were picked up from DI and set on the static property + Assert.NotNull(KustoInstrumentation.TraceOptions); + Assert.NotNull(KustoInstrumentation.TraceOptions.Enrich); + + // Verify the Enrich callback works + KustoInstrumentation.TraceOptions.Enrich(null!, default!); + Assert.True(enrichCalled); + } + + [Fact] + public void TestMeterOptionsDiConfig() + { + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .ConfigureServices(services => + { + services.Configure(options => + { + options.RecordQueryText = true; + options.RecordQuerySummary = false; + }); + }) + .AddKustoInstrumentation(configureKustoMeterInstrumentationOptions: null) + .Build(); + + // Assert that the options were picked up from DI and set on the static property + Assert.NotNull(KustoInstrumentation.MeterOptions); + Assert.True(KustoInstrumentation.MeterOptions.RecordQueryText); + Assert.False(KustoInstrumentation.MeterOptions.RecordQuerySummary); + } + + [Fact] + public void TestTraceAndMeterOptionsDiConfigTogether() + { + var enrichCalled = false; + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .ConfigureServices(services => + { + services.Configure(options => + { + options.Enrich = (activity, record) => { enrichCalled = true; }; + }); + }) + .AddKustoInstrumentation(configureKustoTraceInstrumentationOptions: null) + .Build(); + + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .ConfigureServices(services => + { + services.Configure(options => + { + options.RecordQueryText = true; + options.RecordQuerySummary = false; + }); + }) + .AddKustoInstrumentation(configureKustoMeterInstrumentationOptions: null) + .Build(); + + // Assert that both options were picked up from DI and set on the static properties + Assert.NotNull(KustoInstrumentation.TraceOptions); + Assert.NotNull(KustoInstrumentation.TraceOptions.Enrich); + + Assert.NotNull(KustoInstrumentation.MeterOptions); + Assert.True(KustoInstrumentation.MeterOptions.RecordQueryText); + Assert.False(KustoInstrumentation.MeterOptions.RecordQuerySummary); + + // Verify the Enrich callback works + KustoInstrumentation.TraceOptions.Enrich(null!, default!); + Assert.True(enrichCalled); + } + + [Fact] + public void TestTraceOptionsWithCallbackOverridesDi() + { + var enrichFromDiCalled = false; + var enrichFromCallbackCalled = false; + + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .ConfigureServices(services => + { + services.Configure(options => + { + // This should be overridden by the callback + options.Enrich = (activity, record) => { enrichFromDiCalled = true; }; + }); + }) + .AddKustoInstrumentation(configureKustoTraceInstrumentationOptions: options => + { + // Callback should override DI configuration + options.Enrich = (activity, record) => { enrichFromCallbackCalled = true; }; + }) + .Build(); + + // Assert that the callback options were used, not the DI options + Assert.NotNull(KustoInstrumentation.TraceOptions); + Assert.NotNull(KustoInstrumentation.TraceOptions.Enrich); + + // Verify the callback version is used + KustoInstrumentation.TraceOptions.Enrich(null!, default!); + Assert.False(enrichFromDiCalled); + Assert.True(enrichFromCallbackCalled); + } + + [Fact] + public void TestMeterOptionsWithCallbackOverridesDi() + { + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .ConfigureServices(services => + { + services.Configure(options => + { + // This should be overridden by the callback + options.RecordQueryText = false; + options.RecordQuerySummary = true; + }); + }) + .AddKustoInstrumentation(configureKustoMeterInstrumentationOptions: options => + { + // Callback should override DI configuration + options.RecordQueryText = true; + options.RecordQuerySummary = false; + }) + .Build(); + + // Assert that the callback options were used, not the DI options + Assert.NotNull(KustoInstrumentation.MeterOptions); + Assert.True(KustoInstrumentation.MeterOptions.RecordQueryText); + Assert.False(KustoInstrumentation.MeterOptions.RecordQuerySummary); + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoIntegrationTests.cs b/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoIntegrationTests.cs new file mode 100644 index 0000000000..b10105f336 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoIntegrationTests.cs @@ -0,0 +1,357 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Data; +using System.Diagnostics; +using System.Text; +using Kusto.Data.Common; +using Kusto.Data.Net.Client; +using OpenTelemetry.Instrumentation.Kusto.Implementation; +using OpenTelemetry.Metrics; +using OpenTelemetry.Tests; +using OpenTelemetry.Trace; +using Xunit; + +namespace OpenTelemetry.Instrumentation.Kusto.Tests; + +[Trait("CategoryName", "KustoIntegrationTests")] +public sealed class KustoIntegrationTests : IClassFixture +{ + private readonly KustoIntegrationTestsFixture fixture; + + public KustoIntegrationTests(KustoIntegrationTestsFixture fixture) + { + this.fixture = fixture; + } + + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] + [InlineData("print number=42", true)] + [InlineData("print number=42", false)] + public async Task SuccessfulQueryTest(string query, bool processQuery) + { + var activities = new List(); + var metrics = new List(); + + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddInMemoryExporter(activities) + .AddKustoInstrumentation(options => + { + options.RecordQueryText = processQuery; + options.RecordQuerySummary = processQuery; + }) + .Build(); + + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddInMemoryExporter(metrics) + .AddKustoInstrumentation(options => + { + options.RecordQueryText = processQuery; + options.RecordQuerySummary = processQuery; + }) + .Build(); + + var kcsb = this.fixture.ConnectionStringBuilder; + using var queryProvider = KustoClientFactory.CreateCslQueryProvider(kcsb); + + var crp = new ClientRequestProperties() + { + // Ensure a stable client ID for snapshots + ClientRequestId = Convert.ToBase64String(Encoding.UTF8.GetBytes(query)), + }; + + using var reader = queryProvider.ExecuteQuery("NetDefaultDB", query, crp); + reader.Consume(); + + tracerProvider.ForceFlush(); + meterProvider.ForceFlush(); + + await Verify( + new + { + Activities = FilterActivites(activities), + Metrics = FilterMetrics(metrics), + }) + .ScrubHostname(kcsb.Hostname) + .ScrubPort(this.fixture.DatabaseContainer.GetMappedPublicPort()) + .UseDirectory("Snapshots") + .UseParameters(query, processQuery); + } + + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] + [InlineData("print number=42")] + public async Task TraceOnlyTest(string query) + { + var activities = new List(); + + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddInMemoryExporter(activities) + .AddKustoInstrumentation(options => + { + options.RecordQueryText = true; + options.RecordQuerySummary = false; + }) + .Build(); + + var kcsb = this.fixture.ConnectionStringBuilder; + using var queryProvider = KustoClientFactory.CreateCslQueryProvider(kcsb); + + var crp = new ClientRequestProperties() + { + // Ensure a stable client ID for snapshots + ClientRequestId = Convert.ToBase64String(Encoding.UTF8.GetBytes(query)), + }; + + using var reader = queryProvider.ExecuteQuery("NetDefaultDB", query, crp); + reader.Consume(); + + tracerProvider.ForceFlush(); + + await Verify( + new + { + Activities = FilterActivites(activities), + }) + .ScrubHostname(kcsb.Hostname) + .ScrubPort(this.fixture.DatabaseContainer.GetMappedPublicPort()) + .UseDirectory("Snapshots") + .UseParameters(query); + } + + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] + [InlineData("print number=42")] + public async Task MetricsOnlyTest(string query) + { + var metrics = new List(); + + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddInMemoryExporter(metrics) + .AddKustoInstrumentation() + .Build(); + + var kcsb = this.fixture.ConnectionStringBuilder; + using var queryProvider = KustoClientFactory.CreateCslQueryProvider(kcsb); + + var crp = new ClientRequestProperties() + { + // Ensure a stable client ID for snapshots + ClientRequestId = Convert.ToBase64String(Encoding.UTF8.GetBytes(query)), + }; + + using var reader = queryProvider.ExecuteQuery("NetDefaultDB", query, crp); + reader.Consume(); + + meterProvider.ForceFlush(); + + await Verify( + new + { + Metrics = FilterMetrics(metrics), + }) + .ScrubHostname(kcsb.Hostname) + .ScrubPort(this.fixture.DatabaseContainer.GetMappedPublicPort()) + .UseDirectory("Snapshots") + .UseParameters(query); + } + + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] + [InlineData("InvalidTable | take 10", true)] + [InlineData("InvalidTable | take 10", false)] + public async Task FailedQueryTest(string query, bool processQuery) + { + var activities = new List(); + var metrics = new List(); + + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddInMemoryExporter(activities) + .AddKustoInstrumentation(options => + { + options.RecordQueryText = processQuery; + options.RecordQuerySummary = processQuery; + }) + .Build(); + + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddInMemoryExporter(metrics) + .AddKustoInstrumentation(options => + { + options.RecordQueryText = processQuery; + options.RecordQuerySummary = processQuery; + }) + .Build(); + + var kcsb = this.fixture.ConnectionStringBuilder; + using var queryProvider = KustoClientFactory.CreateCslQueryProvider(kcsb); + + var crp = new ClientRequestProperties() + { + // Ensure a stable client ID for snapshots + ClientRequestId = Convert.ToBase64String(Encoding.UTF8.GetBytes(query)), + }; + + // Execute the query and expect an exception + var exception = await Assert.ThrowsAnyAsync(async () => + { + using var reader = queryProvider.ExecuteQuery("NetDefaultDB", query, crp); + reader.Consume(); + + await Task.CompletedTask; + }); + + tracerProvider.ForceFlush(); + meterProvider.ForceFlush(); + + await Verify( + new + { + Activities = FilterActivites(activities), + Metrics = FilterMetrics(metrics), + Exception = new + { + Type = exception.GetType().FullName, + HasMessage = !string.IsNullOrEmpty(exception.Message), + }, + }) + .ScrubHostname(kcsb.Hostname) + .ScrubPort(this.fixture.DatabaseContainer.GetMappedPublicPort()) + .UseDirectory("Snapshots") + .UseParameters(query, processQuery); + } + + [EnabledOnDockerPlatformFact(DockerPlatform.Linux)] + public void NoInstrumentationRegistered_NoEventsEmitted() + { + // Arrange + var activities = new List(); + var metrics = new List(); + + // Create providers WITHOUT Kusto instrumentation + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddInMemoryExporter(activities) + .Build(); + + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddInMemoryExporter(metrics) + .Build(); + + var kcsb = this.fixture.ConnectionStringBuilder; + using var queryProvider = KustoClientFactory.CreateCslQueryProvider(kcsb); + + var crp = new ClientRequestProperties() + { + ClientRequestId = "test-no-instrumentation", + }; + + // Act + using var reader = queryProvider.ExecuteQuery("NetDefaultDB", "print number=42", crp); + reader.Consume(); + + tracerProvider.ForceFlush(); + meterProvider.ForceFlush(); + + Assert.Empty(FilterActivites(activities)); + Assert.Empty(FilterMetrics(metrics)); + } + + [EnabledOnDockerPlatformFact(DockerPlatform.Linux)] + public void EnrichCallbackTest() + { + // Arrange + var activities = new List(); + + // Query with comment for custom summary + const string key = "otel-custom-summary="; + const string summary = "MyOperation"; + var query = $"// {key}{summary}\nprint number=42"; + + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddInMemoryExporter(activities) + .AddKustoInstrumentation(options => + { + // Disable automatic summarization + options.RecordQuerySummary = false; + + // Extract the comment from the query text and set the summary attribute manually + options.Enrich = (activity, record) => + { + var message = record.Message.AsSpan(); + var begin = message.IndexOf(key, StringComparison.Ordinal); + + if (begin < 0) + { + return; + } + + var summary = message.Slice(begin + key.Length); + var end = summary.IndexOfAny('\r', '\n'); + if (end < 0) + { + end = summary.Length; + } + + summary = summary.Slice(0, end).Trim(); + var summaryString = summary.ToString(); + + activity.SetTag(SemanticConventions.AttributeDbQuerySummary, summaryString); + activity.DisplayName = summaryString; + }; + }) + .Build(); + + var kcsb = this.fixture.ConnectionStringBuilder; + using var queryProvider = KustoClientFactory.CreateCslQueryProvider(kcsb); + + var crp = new ClientRequestProperties() + { + ClientRequestId = "test-enrich-callback", + }; + + // Act + using var reader = queryProvider.ExecuteQuery("NetDefaultDB", query, crp); + reader.Consume(); + + tracerProvider.ForceFlush(); + + // Assert + var kustoActivities = activities + .Where(activity => activity.Source == KustoActivitySourceHelper.ActivitySource) + .ToList(); + + Assert.Single(kustoActivities); + var activity = kustoActivities[0]; + + // Verify the custom summary was set by the Enrich callback + var querySummaryTag = activity.TagObjects.SingleOrDefault(t => t.Key == SemanticConventions.AttributeDbQuerySummary); + Assert.NotNull(querySummaryTag.Key); + Assert.Equal(summary, querySummaryTag.Value); + + // Verify the display name was set to the custom summary + Assert.Equal(summary, activity.DisplayName); + } + + private static dynamic FilterActivites(IEnumerable activities) => + activities + .Where(activity => activity.Source == KustoActivitySourceHelper.ActivitySource) + .Select(activity => new + { + ActivitySourceName = activity.Source.Name, + activity.DisplayName, + activity.Status, + activity.StatusDescription, + activity.TagObjects, + activity.OperationName, + activity.IdFormat, + }); + + private static dynamic FilterMetrics(IEnumerable metrics) => + metrics + .Where(metric => metric.MeterName == KustoActivitySourceHelper.MeterName) + .Select(metric => new + { + metric.MeterName, + metric.Name, + metric.Description, + metric.MeterTags, + metric.Unit, + metric.Temporality, + }); +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoIntegrationTestsFixture.cs b/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoIntegrationTestsFixture.cs new file mode 100644 index 0000000000..4475380bfb --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoIntegrationTestsFixture.cs @@ -0,0 +1,39 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Kusto.Data; +using Testcontainers.Kusto; +using Xunit; + +namespace OpenTelemetry.Instrumentation.Kusto.Tests; + +public sealed class KustoIntegrationTestsFixture : IAsyncLifetime +{ + private static readonly string KustoImage = GetKustoImage(); + + public KustoContainer DatabaseContainer { get; } = CreateKusto(); + + public KustoConnectionStringBuilder ConnectionStringBuilder => new(this.DatabaseContainer.GetConnectionString()); + + public Task InitializeAsync() => this.DatabaseContainer.StartAsync(); + + public Task DisposeAsync() => this.DatabaseContainer.DisposeAsync().AsTask(); + + private static KustoContainer CreateKusto() + => new KustoBuilder() + .WithImage(KustoImage) + .Build(); + + private static string GetKustoImage() + { + var assembly = typeof(KustoIntegrationTestsFixture).Assembly; + + using var stream = assembly.GetManifestResourceStream("kusto.Dockerfile"); + using var reader = new StreamReader(stream!); + + var raw = reader.ReadToEnd(); + + // Exclude FROM + return raw.Substring(4).Trim(); + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoMeterProviderBuilderTests.cs b/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoMeterProviderBuilderTests.cs new file mode 100644 index 0000000000..9a29455e57 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoMeterProviderBuilderTests.cs @@ -0,0 +1,28 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.Metrics; +using Xunit; + +namespace OpenTelemetry.Instrumentation.Kusto.Tests; + +public class KustoMeterProviderBuilderTests +{ + [Fact] + public void AddKustoInstrumentation_DoesNotThrow() + { + var builder = Sdk.CreateMeterProviderBuilder(); + + var actual = builder.AddKustoInstrumentation(); + + Assert.Same(builder, actual); + } + + [Fact] + public void AddKustoInstrumentation_WithNullBuilder_ThrowsArgumentNullException() + { + MeterProviderBuilder builder = null!; + + Assert.Throws(() => builder.AddKustoInstrumentation()); + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoQueryParserTests.cs b/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoQueryParserTests.cs new file mode 100644 index 0000000000..333c760976 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoQueryParserTests.cs @@ -0,0 +1,385 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.Instrumentation.Kusto.Implementation; +using Xunit; + +namespace OpenTelemetry.Instrumentation.Kusto.Tests; + +public class KustoQueryParserTests +{ + public static TheoryData QuerySummaryTestCases => new() + { + // Null / empty / invalid + // NOTE: In these cases, there's no objectively correct answer, so the main goal + // is to ensure we handle error cases gracefully + { + string.Empty, + string.Empty, + string.Empty + }, + { + " \t\n ", + string.Empty, + " \t\n " + }, + { + "this is not a valid query @#$%", + "this is a valid query", + "this is not a valid query @#$%" + }, + { + "StormEvents |", + "StormEvents |", + "StormEvents |" + }, + + // Simple table reference + { + "StormEvents", + "StormEvents", + "StormEvents" + }, + + // Print statement + { + "print number=42", + "print", + "print number=?" + }, + + // Pipes + { + "StormEvents | where State == 'FLORIDA'", + "StormEvents | where", + "StormEvents | where State == ?" + }, + { + "StormEvents | project State, EventType", + "StormEvents | project", + "StormEvents | project State, EventType" + }, + { + "StormEvents | summarize count() by State", + "StormEvents | summarize", + "StormEvents | summarize count() by State" + }, + { + "StormEvents | where State == 'FLORIDA' | project State, EventType | take 10", + "StormEvents | where | project | take", + "StormEvents | where State == ? | project State, EventType | take ?" + }, + { + "StormEvents | where State == 'CA' | extend NewCol = 1 | project State | summarize count() | order by count_", + "StormEvents | where | extend | project | summarize | order", + "StormEvents | where State == ? | extend NewCol = ? | project State | summarize count() | order by count_" + }, + + // Database function + { + "database('SampleDB').StormEvents", + "StormEvents", + "database(?).StormEvents" + }, + + // Let statement + { + "let threshold = 5; StormEvents | where DamageProperty > threshold", + "StormEvents | where", + "let threshold = ?; StormEvents | where DamageProperty > threshold" + }, + { + "let x = 10; let y = 20; StormEvents | take x", + "StormEvents | take", + "let x = ?; let y = ?; StormEvents | take x" + }, + + // Parameterized queries + { + "declare query_parameters(maxInjured:long = 90);\nStormEvents\n| where InjuriesDirect + InjuriesIndirect > maxInjured\n| where EventType = 1\n| project EpisodeId, EventType, totalInjuries = InjuriesDirect + InjuriesIndirect", + "StormEvents | where | where | project", + "declare query_parameters(maxInjured:long = 90);\nStormEvents\n| where InjuriesDirect + InjuriesIndirect > maxInjured\n| where EventType = 1\n| project EpisodeId, EventType, totalInjuries = InjuriesDirect + InjuriesIndirect" + }, + + // Nested queries + { + "StormEvents | union OtherEvents", + "StormEvents | union OtherEvents", + "StormEvents | union OtherEvents" + }, + { + "StormEvents | join kind=inner (PopulationData) on State", + "StormEvents | join PopulationData", + "StormEvents | join kind=? (PopulationData) on State" + }, + { + "let threshold = 1000;\nStormEvents\n| where DamageProperty > threshold\n| join kind=inner (\n PopulationData\n | where Year == 2020\n) on State\n| summarize TotalDamage = sum(DamageProperty) by State\n| top 10 by TotalDamage", + "StormEvents | where | join PopulationData | where | summarize | top", + "let threshold = ?;\nStormEvents\n| where DamageProperty > threshold\n| join kind=? (\n PopulationData\n | where Year == ?\n) on State\n| summarize TotalDamage = sum(DamageProperty) by State\n| top ? by TotalDamage" + }, + { + "StormEvents | union WeatherEvents | where State == 'TX'", + "StormEvents | union WeatherEvents | where", + "StormEvents | union WeatherEvents | where State == ?" + }, + + // Control command + { + ".show databases", + ".show", + ".show databases" + }, + + // Range + { + "range x from 1 to 10 step 1", + "range", + "range x from ? to ? step ?" + }, + { + "StormEvents | where Value between (10 .. 100)", + "StormEvents | where", + "StormEvents | where Value between (? .. ?)" + }, + + // DataTable + { + "datatable(name:string, age:int) ['Alice', 30, 'Bob', 25]", + "datatable", + "datatable(name:string, age:int) [?, ?, ?, ?]" + }, + + // Query with newlines + { + "StormEvents\n| where State == 'FLORIDA'\n| project State, EventType\n| take 10", + "StormEvents | where | project | take", + "StormEvents\n| where State == ?\n| project State, EventType\n| take ?" + }, + + // Query with tabs + { + "StormEvents\t|\twhere\tState\t==\t'FLORIDA'", + "StormEvents | where", + "StormEvents\t|\twhere\tState\t==\t?" + }, + + // Comments + // NOTE: Ideally comments would be stripped from sanitized queries, but Kusto.Language does not easily allow for + // stripping embedded comments, so codifying the behavior for now. If this becomes a problem we can revisit. + { + "// Single line comment\nStormEvents | where State == 'TX'", + "StormEvents | where", + "// Single line comment\nStormEvents | where State == ?" + }, + { + "StormEvents | take 10 // Get first 10 rows", + "StormEvents | take", + "StormEvents | take ? // Get first 10 rows" + }, + + // Number parsing + { + "StormEvents | where Temperature < -10", + "StormEvents | where", + "StormEvents | where Temperature < ?" + }, + { + "StormEvents | where Value == -42.5", + "StormEvents | where", + "StormEvents | where Value == ?" + }, + { + "StormEvents | where Count > +100", + "StormEvents | where", + "StormEvents | where Count > ?" + }, + { + "StormEvents | where Value > 1.5e10", + "StormEvents | where", + "StormEvents | where Value > ?" + }, + { + "StormEvents | where Value < 3.14E-5", + "StormEvents | where", + "StormEvents | where Value < ?" + }, + { + "StormEvents | where Value == -2.5e+8", + "StormEvents | where", + "StormEvents | where Value == ?" + }, + { + "StormEvents | where Price == 123.456", + "StormEvents | where", + "StormEvents | where Price == ?" + }, + + // Mixed string and numeric literals + { + "StormEvents | where State == 'FL' and Temp > 90", + "StormEvents | where", + "StormEvents | where State == ? and Temp > ?" + }, + + // Double-quoted strings + { + "print text = \"Hello World\"", + "print", + "print text = ?" + }, + + // Empty strings + { + "StormEvents | where State != ''", + "StormEvents | where", + "StormEvents | where State != ?" + }, + + // Nested parentheses + { + "StormEvents | where (State == 'CA' and (Temp > 80))", + "StormEvents | where", + "StormEvents | where (State == ? and (Temp > ?))" + }, + + // Boolean literals + { + "StormEvents | where IsActive == true", + "StormEvents | where", + "StormEvents | where IsActive == ?" + }, + { + "StormEvents | where IsDeleted == false", + "StormEvents | where", + "StormEvents | where IsDeleted == ?" + }, + { + "print flag=true", + "print", + "print flag=?" + }, + { + "StormEvents | extend Active = false", + "StormEvents | extend", + "StormEvents | extend Active = ?" + }, + + // DateTime literals + { + "StormEvents | where StartTime > datetime(2020-01-01)", + "StormEvents | where", + "StormEvents | where StartTime > ?" + }, + { + "StormEvents | where EventTime >= datetime('2021-05-15T10:30:00Z')", + "StormEvents | where", + "StormEvents | where EventTime >= ?" + }, + { + "StormEvents | where timestamp > datetime(2023-12-25)", + "StormEvents | where", + "StormEvents | where timestamp > ?" + }, + + // TimeSpan/duration literals + { + "StormEvents | where Duration > 1h", + "StormEvents | where", + "StormEvents | where Duration > ?" + }, + { + "StormEvents | where StartTime < ago(7d)", + "StormEvents | where", + "StormEvents | where StartTime < ago(?)" + }, + { + "StormEvents | where TimeDiff < 30m", + "StormEvents | where", + "StormEvents | where TimeDiff < ?" + }, + { + "StormEvents | where Duration == 2.5h", + "StormEvents | where", + "StormEvents | where Duration == ?" + }, + + // GUID literals + { + "StormEvents | where Id == guid(12345678-1234-1234-1234-123456789012)", + "StormEvents | where", + "StormEvents | where Id == ?" + }, + { + "Users | where UserId == guid('a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d')", + "Users | where", + "Users | where UserId == ?" + }, + + // Dynamic/JSON literals + { + "StormEvents | where Data == dynamic({\"key\":\"value\"})", + "StormEvents | where", + "StormEvents | where Data == ?" + }, + { + "StormEvents | extend Props = dynamic(['a', 'b', 'c'])", + "StormEvents | extend", + "StormEvents | extend Props = ?" + }, + { + "print obj=dynamic({\"x\":1,\"y\":2})", + "print", + "print obj=?" + }, + + // Binary/Hexadecimal literals + { + "StormEvents | where Flags == 0x1F", + "StormEvents | where", + "StormEvents | where Flags == ?" + }, + { + "StormEvents | where Mask == 0xFF00", + "StormEvents | where", + "StormEvents | where Mask == ?" + }, + { + "print hex=0xDEADBEEF", + "print", + "print hex=?" + }, + + // Interval literals + { + "StormEvents | where StartTime between (datetime(2007-07-27) .. ago(1d))", + "StormEvents | where", + "StormEvents | where StartTime between (? .. ago(?))" + }, + + // Materialized view + // NOTE: Ideally the summarizer would strip "Condition", but it this context it is ambiguous with a table reference. Given the prevelance of this type of query, + // codifying the behavior for now. If this becomes a problem we can revisit. + { + "database('*').materialized_view('ViewName') | where Condition == 'Value'", + "materialized_view('ViewName') | where Condition", + "database(?).materialized_view(?) | where Condition == ?" + }, + + // Long query - tests 255 character truncation + { + "StormEvents | where State == 'CALIFORNIA' | extend NewColumn1 = 1 | extend NewColumn2 = 2 | extend NewColumn3 = 3 | extend NewColumn4 = 4 | extend NewColumn5 = 5 | extend NewColumn6 = 6 | extend NewColumn7 = 7 | extend NewColumn8 = 8 | extend NewColumn9 = 9 | extend NewColumn10 = 10 | extend NewColumn11 = 11 | extend NewColumn12 = 12 | extend NewColumn13 = 13 | extend NewColumn14 = 14 | extend NewColumn15 = 15 | extend NewColumn16 = 16 | extend NewColumn17 = 17 | extend NewColumn18 = 18 | extend NewColumn19 = 19 | extend NewColumn20 = 20 | extend NewColumn21 = 21 | extend NewColumn22 = 22 | extend NewColumn23 = 23 | extend NewColumn24 = 24 | extend NewColumn25 = 25 | extend NewColumn26 = 26 | extend NewColumn27 = 27", + "StormEvents | where | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend | extend |", + "StormEvents | where State == ? | extend NewColumn1 = ? | extend NewColumn2 = ? | extend NewColumn3 = ? | extend NewColumn4 = ? | extend NewColumn5 = ? | extend NewColumn6 = ? | extend NewColumn7 = ? | extend NewColumn8 = ? | extend NewColumn9 = ? | extend NewColumn10 = ? | extend NewColumn11 = ? | extend NewColumn12 = ? | extend NewColumn13 = ? | extend NewColumn14 = ? | extend NewColumn15 = ? | extend NewColumn16 = ? | extend NewColumn17 = ? | extend NewColumn18 = ? | extend NewColumn19 = ? | extend NewColumn20 = ? | extend NewColumn21 = ? | extend NewColumn22 = ? | extend NewColumn23 = ? | extend NewColumn24 = ? | extend NewColumn25 = ? | extend NewColumn26 = ? | extend NewColumn27 = ?" + }, + }; + + [Theory] + [MemberData(nameof(QuerySummaryTestCases))] + public void GenerateQuerySummary_ReturnsExpectedSummary(string query, string? expectedSummary, string? expectedSanitizedQuery) + { + var info = KustoProcessor.Process(shouldSummarize: true, shouldSanitize: true, query); + + Assert.Equal(expectedSummary, info.Summarized); + Assert.Equal(expectedSanitizedQuery, info.Sanitized); + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoTraceProviderBuilderTests.cs b/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoTraceProviderBuilderTests.cs new file mode 100644 index 0000000000..93489d7331 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/KustoTraceProviderBuilderTests.cs @@ -0,0 +1,46 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.Trace; +using Xunit; + +namespace OpenTelemetry.Instrumentation.Kusto.Tests; + +public class KustoTraceProviderBuilderTests +{ + [Fact] + public void AddKustoInstrumentation_DoesNotThrow() + { + var builder = Sdk.CreateTracerProviderBuilder(); + + var actual = builder.AddKustoInstrumentation(); + + Assert.Same(builder, actual); + } + + [Fact] + public void AddKustoInstrumentation_WithNullBuilder_ThrowsArgumentNullException() + { + TracerProviderBuilder builder = null!; + + Assert.Throws(() => builder.AddKustoInstrumentation()); + } + + [Fact] + public void AddKustoInstrumentation_WithOptions_DoesNotThrow() + { + var builder = Sdk.CreateTracerProviderBuilder(); + + var actual = builder.AddKustoInstrumentation(options => options.Enrich = (activity, record) => { }); + + Assert.Same(builder, actual); + } + + [Fact] + public void KustoTraceInstrumentationOptions_DefaultEnrichIsNull() + { + var options = new KustoTraceInstrumentationOptions(); + + Assert.Null(options.Enrich); + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/OpenTelemetry.Instrumentation.Kusto.Tests.csproj b/test/OpenTelemetry.Instrumentation.Kusto.Tests/OpenTelemetry.Instrumentation.Kusto.Tests.csproj new file mode 100644 index 0000000000..2a189280ce --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/OpenTelemetry.Instrumentation.Kusto.Tests.csproj @@ -0,0 +1,41 @@ + + + + $(SupportedNetTargets) + $(TargetFrameworks);$(NetFrameworkMinimumSupportedVersion) + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.FailedQueryTest_query=InvalidTable - take 10_processQuery=False.verified.txt b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.FailedQueryTest_query=InvalidTable - take 10_processQuery=False.verified.txt new file mode 100644 index 0000000000..1a21a7102a --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.FailedQueryTest_query=InvalidTable - take 10_processQuery=False.verified.txt @@ -0,0 +1,48 @@ +{ + Activities: [ + { + ActivitySourceName: OpenTelemetry.Instrumentation.Kusto, + DisplayName: KD.RestClient.ExecuteQuery, + Status: Error, + StatusDescription: 'take' operator: Failed to resolve table or column expression named 'InvalidTable', + TagObjects: [ + { + azure.kusto.client_request_id: SW52YWxpZFRhYmxlIHwgdGFrZSAxMA== + }, + { + db.system.name: azure.kusto + }, + { + db.operation.name: KD.RestClient.ExecuteQuery + }, + { + server.address: Scrubbed + }, + { + server.port: Scrubbed + }, + { + db.namespace: NetDefaultDB + }, + { + error.type: Kusto.Data.Exceptions.SemanticException + } + ], + OperationName: KD.RestClient.ExecuteQuery, + IdFormat: W3C + } + ], + Metrics: [ + { + MeterName: OpenTelemetry.Instrumentation.Kusto, + Name: db.client.operation.duration, + Description: Duration of database client operations, + Unit: s, + Temporality: Cumulative + } + ], + Exception: { + Type: Kusto.Data.Exceptions.SemanticException, + HasMessage: true + } +} \ No newline at end of file diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.FailedQueryTest_query=InvalidTable - take 10_processQuery=True.verified.txt b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.FailedQueryTest_query=InvalidTable - take 10_processQuery=True.verified.txt new file mode 100644 index 0000000000..fab7c9dfda --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.FailedQueryTest_query=InvalidTable - take 10_processQuery=True.verified.txt @@ -0,0 +1,54 @@ +{ + Activities: [ + { + ActivitySourceName: OpenTelemetry.Instrumentation.Kusto, + DisplayName: InvalidTable | take, + Status: Error, + StatusDescription: 'take' operator: Failed to resolve table or column expression named 'InvalidTable', + TagObjects: [ + { + azure.kusto.client_request_id: SW52YWxpZFRhYmxlIHwgdGFrZSAxMA== + }, + { + db.system.name: azure.kusto + }, + { + db.operation.name: KD.RestClient.ExecuteQuery + }, + { + server.address: Scrubbed + }, + { + server.port: Scrubbed + }, + { + db.namespace: NetDefaultDB + }, + { + db.query.text: InvalidTable | take ? + }, + { + db.query.summary: InvalidTable | take + }, + { + error.type: Kusto.Data.Exceptions.SemanticException + } + ], + OperationName: KD.RestClient.ExecuteQuery, + IdFormat: W3C + } + ], + Metrics: [ + { + MeterName: OpenTelemetry.Instrumentation.Kusto, + Name: db.client.operation.duration, + Description: Duration of database client operations, + Unit: s, + Temporality: Cumulative + } + ], + Exception: { + Type: Kusto.Data.Exceptions.SemanticException, + HasMessage: true + } +} \ No newline at end of file diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.MetricsOnlyTest_query=print number=42.verified.txt b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.MetricsOnlyTest_query=print number=42.verified.txt new file mode 100644 index 0000000000..d35fa730f3 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.MetricsOnlyTest_query=print number=42.verified.txt @@ -0,0 +1,11 @@ +{ + Metrics: [ + { + MeterName: OpenTelemetry.Instrumentation.Kusto, + Name: db.client.operation.duration, + Description: Duration of database client operations, + Unit: s, + Temporality: Cumulative + } + ] +} \ No newline at end of file diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.SuccessfulQueryTest_query=print number=42_processQuery=False.verified.txt b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.SuccessfulQueryTest_query=print number=42_processQuery=False.verified.txt new file mode 100644 index 0000000000..cf190459f0 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.SuccessfulQueryTest_query=print number=42_processQuery=False.verified.txt @@ -0,0 +1,40 @@ +{ + Activities: [ + { + ActivitySourceName: OpenTelemetry.Instrumentation.Kusto, + DisplayName: KD.RestClient.ExecuteQuery, + Status: Ok, + TagObjects: [ + { + azure.kusto.client_request_id: cHJpbnQgbnVtYmVyPTQy + }, + { + db.system.name: azure.kusto + }, + { + db.operation.name: KD.RestClient.ExecuteQuery + }, + { + server.address: Scrubbed + }, + { + server.port: Scrubbed + }, + { + db.namespace: NetDefaultDB + } + ], + OperationName: KD.RestClient.ExecuteQuery, + IdFormat: W3C + } + ], + Metrics: [ + { + MeterName: OpenTelemetry.Instrumentation.Kusto, + Name: db.client.operation.duration, + Description: Duration of database client operations, + Unit: s, + Temporality: Cumulative + } + ] +} \ No newline at end of file diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.SuccessfulQueryTest_query=print number=42_processQuery=True.verified.txt b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.SuccessfulQueryTest_query=print number=42_processQuery=True.verified.txt new file mode 100644 index 0000000000..5307354f42 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.SuccessfulQueryTest_query=print number=42_processQuery=True.verified.txt @@ -0,0 +1,46 @@ +{ + Activities: [ + { + ActivitySourceName: OpenTelemetry.Instrumentation.Kusto, + DisplayName: print, + Status: Ok, + TagObjects: [ + { + azure.kusto.client_request_id: cHJpbnQgbnVtYmVyPTQy + }, + { + db.system.name: azure.kusto + }, + { + db.operation.name: KD.RestClient.ExecuteQuery + }, + { + server.address: Scrubbed + }, + { + server.port: Scrubbed + }, + { + db.namespace: NetDefaultDB + }, + { + db.query.text: print number=? + }, + { + db.query.summary: print + } + ], + OperationName: KD.RestClient.ExecuteQuery, + IdFormat: W3C + } + ], + Metrics: [ + { + MeterName: OpenTelemetry.Instrumentation.Kusto, + Name: db.client.operation.duration, + Description: Duration of database client operations, + Unit: s, + Temporality: Cumulative + } + ] +} \ No newline at end of file diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.TraceOnlyTest_query=print number=42.verified.txt b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.TraceOnlyTest_query=print number=42.verified.txt new file mode 100644 index 0000000000..de2c4d774d --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/Snapshots/KustoIntegrationTests.TraceOnlyTest_query=print number=42.verified.txt @@ -0,0 +1,34 @@ +{ + Activities: [ + { + ActivitySourceName: OpenTelemetry.Instrumentation.Kusto, + DisplayName: KD.RestClient.ExecuteQuery, + Status: Ok, + TagObjects: [ + { + azure.kusto.client_request_id: cHJpbnQgbnVtYmVyPTQy + }, + { + db.system.name: azure.kusto + }, + { + db.operation.name: KD.RestClient.ExecuteQuery + }, + { + server.address: Scrubbed + }, + { + server.port: Scrubbed + }, + { + db.namespace: NetDefaultDB + }, + { + db.query.text: print number=? + } + ], + OperationName: KD.RestClient.ExecuteQuery, + IdFormat: W3C + } + ] +} \ No newline at end of file diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/TraceRecordParserTests.cs b/test/OpenTelemetry.Instrumentation.Kusto.Tests/TraceRecordParserTests.cs new file mode 100644 index 0000000000..c4bed986be --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/TraceRecordParserTests.cs @@ -0,0 +1,173 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.Instrumentation.Kusto.Implementation; +using Xunit; + +namespace OpenTelemetry.Instrumentation.Kusto.Tests; + +public class TraceRecordParserTests +{ + [Fact] + public void ParseRequestStartSuccess() + { + const string message = "$$HTTPREQUEST[RestClient2]: Verb=POST, Uri=http://127.0.0.1:49902/v1/rest/message, DatabaseName=NetDefaultDB, App=testhost, User=REDMOND\\mattkot, ClientVersion=Kusto.Dotnet.Client:{14.0.2+b2d66614da1a4ff4561c5037c48e5be7002d66d4}|Runtime:{.NET_10.0.0/CLRv10.0.0/10.0.0-rtm.25523.111}, ClientRequestId=SW52YWxpZFRhYmxlIHwgdGFrZSAxMCB8IHdoZXJlIENvbDEgPSA3, text=InvalidTable | take 10 | where Col1=7 | summarize by Date, Time"; + var result = TraceRecordParser.ParseRequestStart(message); + + Assert.Equal("127.0.0.1", result.ServerAddress); + Assert.Equal("49902", result.ServerPort.ToString()); + Assert.Equal("NetDefaultDB", result.Database.ToString()); + Assert.Equal("InvalidTable | take 10 | where Col1=7 | summarize by Date, Time", result.QueryText.ToString()); + } + + [Fact] + public void ParseRequestStartFailure() + { + const string message = "$$HTTPREQUEST[RestClient2]: Verb=POST, Uri=http://"; + var result = TraceRecordParser.ParseRequestStart(message); + + Assert.Null(result.ServerAddress); + Assert.Equal(string.Empty, result.ServerPort.ToString()); + Assert.Equal(string.Empty, result.Database.ToString()); + Assert.Equal(string.Empty, result.QueryText.ToString()); + } + + [Theory] + [InlineData("$$HTTPREQUEST[RestClient2]: Verb=POST, Uri=https://clustername.kusto.windows.net/v1/rest/query, DatabaseName=TestDB, text=print 1", "clustername.kusto.windows.net", 443)] + [InlineData("$$HTTPREQUEST[RestClient2]: Verb=POST, Uri=http://localhost/v1/rest/query, DatabaseName=TestDB, text=print 1", "localhost", 80)] + [InlineData("$$HTTPREQUEST[RestClient2]: Verb=POST, Uri=http://[2001:db8::1]:8080/v1/rest/query, DatabaseName=TestDB, text=print 1", "[2001:db8::1]", 8080)] + [InlineData("$$HTTPREQUEST[RestClient2]: Verb=POST, Uri=https://[2001:db8::1]/v1/rest/query, DatabaseName=TestDB, text=print 1", "[2001:db8::1]", 443)] + public void ParseRequestStartServerAddressAndPort(string message, string expectedAddress, int? expectedPort) + { + var result = TraceRecordParser.ParseRequestStart(message); + Assert.Equal(expectedAddress, result.ServerAddress); + Assert.Equal(expectedPort, result.ServerPort); + } + + [Fact] + public void ParseActivityComplete() + { + const string message = "MonitoredActivityCompletedSuccessfully: ActivityType=KD.RestClient.ExecuteQuery, Timestamp=2025-12-01T02:30:30.0211167Z, ParentActivityId=52707aa6-de7f-42dd-adb9-bc3e6d976fa6, Duration=4316.802 [ms], HowEnded=Success"; + var result = TraceRecordParser.ParseActivityComplete(message); + + Assert.Equal("Success", result.HowEnded.ToString()); + } + + [Fact] + public void ParseActivityCompleteFailure() + { + const string message = "MonitoredActivityCompletedSuccessfully: ActivityType=KD.RestClient.ExecuteQuery, Timestamp=2025-12-01T02:30:30.0211167Z, ParentActivityId=52707aa6-de7f-42dd-adb9-bc3e6d976fa6, Duration=4316.802 [ms]"; + var result = TraceRecordParser.ParseActivityComplete(message); + + Assert.Equal(string.Empty, result.HowEnded.ToString()); + } + + [Fact] + public void ParseException() + { + const string message = + """ + Exception object created: Kusto.Data.Exceptions.SemanticException + [0]Kusto.Data.Exceptions.SemanticException: Semantic error: 'take' operator: Failed to resolve table or column expression named 'InvalidTable' + Timestamp=2025-12-01T02:39:36.3878585Z + ClientRequestId=SW52YWxpZFRhYmxlIHwgdGFrZSAxMA== + ActivityId=b329e166-812e-40e5-9589-5667b8e1329d + ActivityType=KD.RestClient.ExecuteQuery + MachineName=MATTKOT-SURFACE + ProcessName=testhost + ProcessId=44216 + ThreadId=29176 + ActivityStack=(Activity stack: CRID=SW52YWxpZFRhYmxlIHwgdGFrZSAxMA== ARID=b329e166-812e-40e5-9589-5667b8e1329d > KD.RestClient.ExecuteQuery/b329e166-812e-40e5-9589-5667b8e1329d) + MonitoredActivityContext=(ActivityType=KD.RestClient.ExecuteQuery, Timestamp=2025-12-01T02:39:36.1683275Z, ParentActivityId=b329e166-812e-40e5-9589-5667b8e1329d, TimeSinceStarted=219.5397 [ms])ErrorCode=SEM0100 + ErrorReason=BadRequest + ErrorMessage='take' operator: Failed to resolve table or column expression named 'InvalidTable' + DataSource=http://127.0.0.1:62413/v1/rest/query + DatabaseName=NetDefaultDB + ClientRequestId=SW52YWxpZFRhYmxlIHwgdGFrZSAxMA== + ActivityId=ee26fe2b-ae7d-4f9c-807c-117bcae21338 + SemanticErrors='take' operator: Failed to resolve table or column expression named 'InvalidTable' + + at Kusto.Cloud.Platform.Utils.ExceptionsTemplateHelper.Construct_Trace(Exception that, ITraceSource traceSource) + at Kusto.Data.Exceptions.SemanticException.Construct_Trace() + at Kusto.Data.Exceptions.SemanticException.Construct(Boolean deserializing, Nullable`1 failureCode, String failureSubCode, Nullable`1 isPermanent) + at Kusto.Data.Exceptions.SemanticException..ctor(String text, String semanticErrors, String errorCode, String errorReason, String errorMessage, String dataSource, String databaseName, String clientRequestId, Guid activityId, Nullable`1 failureCode, String failureSubCode, Nullable`1 isPermanent) + at Kusto.Data.Net.KustoExceptionUtils.ToKustoException(String responseBody, HttpStatusCode statusCode, String reasonPhrase, KustoExceptionContext context, ITraceSource tracer) + at Kusto.Data.Net.Client.KustoDataHttpClient.ThrowKustoExceptionFromResponseMessageAsync(KustoProtocolResponse response, KustoExceptionContext exceptionContext, HttpResponseMessage responseMessage, ClientRequestProperties properties, Boolean shouldBuffer, Action`2 notify) + at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine) + at Kusto.Data.Net.Client.KustoDataHttpClient.ThrowKustoExceptionFromResponseMessageAsync(KustoProtocolResponse response, KustoExceptionContext exceptionContext, HttpResponseMessage responseMessage, ClientRequestProperties properties, Boolean shouldBuffer, Action`2 notify) + at Kusto.Data.Net.Client.RestClient2.MakeHttpRequestAsyncImpl(RestApi restApi, String address, String csl, String ns, String databaseName, Boolean streaming, ClientRequestProperties properties, ServiceModelTimeoutKind timeoutKind, String clientRequestId, Stream body, StreamProperties streamProperties, CancellationToken cancellationToken, KustoProtocolRequest request, String hostHeaderOverride, HttpMethod httpMethod) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.ExecutionContextCallback(Object s) + at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext() + at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(Action action, Boolean allowInlining) + at System.Threading.Tasks.Task.RunContinuations(Object continuationObject) + at System.Threading.Tasks.Task`1.TrySetResult(TResult result) + at System.Threading.Tasks.Task.TwoTaskWhenAnyPromise`1.Invoke(Task completingTask) + at System.Threading.Tasks.Task.RunContinuations(Object continuationObject) + at System.Net.Http.HttpClient.g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.ExecutionContextCallback(Object s) + at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) + at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(Action action, Boolean allowInlining) + at System.Threading.Tasks.Task.RunContinuations(Object continuationObject) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetResult(TResult result) + at System.Net.Http.SocketsHttpHandler.g__CreateHandlerAndSendAsync|115_0(HttpRequestMessage request, CancellationToken cancellationToken) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.ExecutionContextCallback(Object s) + at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext() + at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(Action action, Boolean allowInlining) + at System.Threading.Tasks.Task.RunContinuations(Object continuationObject) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetExistingTaskResult(Task`1 task, TResult result) + at System.Net.Http.DiagnosticsHandler.SendAsyncCore(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.ExecutionContextCallback(Object s) + at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext() + at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(Action action, Boolean allowInlining) + at System.Threading.Tasks.Task.RunContinuations(Object continuationObject) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetExistingTaskResult(Task`1 task, TResult result) + at System.Net.Http.HttpConnectionPool.SendWithVersionDetectionAndRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.ExecutionContextCallback(Object s) + at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext() + at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(Action action, Boolean allowInlining) + at System.Threading.Tasks.Task.RunContinuations(Object continuationObject) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetResult(TResult result) + at System.Net.Http.HttpConnection.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.ExecutionContextCallback(Object s) + at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext() + at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(Action action, Boolean allowInlining) + at System.Threading.Tasks.Task.RunContinuations(Object continuationObject) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetExistingTaskResult(Task`1 task, TResult result) + at System.Net.Http.HttpConnection.InitialFillAsync(Boolean async) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.ExecutionContextCallback(Object s) + at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread) + at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext() + at System.Net.Sockets.SocketAsyncEventArgs.<>c.<.cctor>b__174_0(UInt32 errorCode, UInt32 numBytes, NativeOverlapped* nativeOverlapped) + at System.Threading.ThreadPoolTypedWorkItemQueue.System.Threading.IThreadPoolWorkItem.Execute() + at System.Threading.ThreadPoolWorkQueue.Dispatch() + at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart() + at System.Threading.Thread.StartCallback() + """; + + var result = TraceRecordParser.ParseException(message); + + Assert.Equal("'take' operator: Failed to resolve table or column expression named 'InvalidTable'", result.ErrorMessage.ToString()); + Assert.Equal("Kusto.Data.Exceptions.SemanticException", result.ErrorType.ToString()); + } + + [Fact] + public void ParseExceptionFailure() + { + const string message = "ProcessName=testhost Timestamp=2025-12-01T02:39:36.3878585Z"; + var result = TraceRecordParser.ParseException(message); + + Assert.Equal(string.Empty, result.ErrorMessage.ToString()); + Assert.Equal(string.Empty, result.ErrorType.ToString()); + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/TruncatingStringBuilderTests.cs b/test/OpenTelemetry.Instrumentation.Kusto.Tests/TruncatingStringBuilderTests.cs new file mode 100644 index 0000000000..4d501eb0d9 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/TruncatingStringBuilderTests.cs @@ -0,0 +1,176 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.Instrumentation.Kusto.Implementation; +using Xunit; + +namespace OpenTelemetry.Instrumentation.Kusto.Tests; + +public class TruncatingStringBuilderTests +{ + [Fact] + public void Append_String_AppendsValue() + { + using var builder = new TruncatingStringBuilder(); + builder.Append("hello"); + builder.Append(" "); + builder.Append("world"); + + Assert.Equal("hello world", builder.ToString()); + Assert.Equal(11, builder.Length); + Assert.False(builder.IsTruncated); + } + + [Fact] + public void Append_Char_AppendsValue() + { + using var builder = new TruncatingStringBuilder(); + builder.Append('a'); + builder.Append('b'); + builder.Append('c'); + + Assert.Equal("abc", builder.ToString()); + Assert.Equal(3, builder.Length); + Assert.False(builder.IsTruncated); + } + + [Fact] + public void Append_Span_AppendsValue() + { + using var builder = new TruncatingStringBuilder(); + builder.Append("hello".AsSpan()); + builder.Append(" ".AsSpan()); + builder.Append("world".AsSpan()); + + Assert.Equal("hello world", builder.ToString()); + Assert.Equal(11, builder.Length); + Assert.False(builder.IsTruncated); + } + + [Fact] + public void Append_ExceedsMaxLength_DoesNotAppendPartialValue() + { + using var builder = new TruncatingStringBuilder(); + + // Fill up to 250 characters + var segment = new string('a', 50); + for (int i = 0; i < 5; i++) + { + builder.Append(segment); + } + + Assert.Equal(250, builder.Length); + Assert.False(builder.IsTruncated); + + // Try to append 10 more characters (would exceed 255) + builder.Append("1234567890"); + + // Should be truncated and not append partial value + Assert.Equal(250, builder.Length); + Assert.True(builder.IsTruncated); + Assert.DoesNotContain("1234567890", builder.ToString()); + } + + [Fact] + public void Append_AfterTruncation_IgnoresFutureAppends() + { + using var builder = new TruncatingStringBuilder(); + + // Fill to 250 characters + builder.Append(new string('a', 250)); + + // Trigger truncation + builder.Append("123456"); + Assert.True(builder.IsTruncated); + Assert.Equal(250, builder.Length); + + // Try to append something that would fit in remaining space + builder.Append("x"); + + // Should still be ignored + Assert.Equal(250, builder.Length); + Assert.DoesNotContain("x", builder.ToString()); + } + + [Fact] + public void Append_ExactlyMaxLength_DoesNotTruncate() + { + using var builder = new TruncatingStringBuilder(); + builder.Append(new string('a', 255)); + + Assert.Equal(255, builder.Length); + Assert.False(builder.IsTruncated); + } + + [Fact] + public void Append_OneCharOverMaxLength_Truncates() + { + using var builder = new TruncatingStringBuilder(); + builder.Append(new string('a', 255)); + builder.Append('b'); + + Assert.Equal(255, builder.Length); + Assert.True(builder.IsTruncated); + Assert.DoesNotContain("b", builder.ToString()); + } + + [Fact] + public void TrimEnd_RemovesTrailingWhitespace() + { + using var builder = new TruncatingStringBuilder(); + builder.Append("hello "); + builder.TrimEnd(); + + Assert.Equal("hello", builder.ToString()); + Assert.Equal(5, builder.Length); + } + + [Fact] + public void TrimEnd_EmptyBuilder_DoesNotThrow() + { + using var builder = new TruncatingStringBuilder(); + builder.TrimEnd(); + + Assert.Equal(string.Empty, builder.ToString()); + Assert.Equal(0, builder.Length); + } + + [Fact] + public void Append_NullString_DoesNothing() + { + using var builder = new TruncatingStringBuilder(); + builder.Append(null!); + + Assert.Equal(string.Empty, builder.ToString()); + Assert.Equal(0, builder.Length); + } + + [Fact] + public void Append_EmptyString_DoesNothing() + { + using var builder = new TruncatingStringBuilder(); + builder.Append(string.Empty); + + Assert.Equal(string.Empty, builder.ToString()); + Assert.Equal(0, builder.Length); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + var builder = new TruncatingStringBuilder(); + builder.Append("test"); + builder.Dispose(); + builder.Dispose(); // Should not throw + } + + [Fact] + public void ToString_AfterDispose_ReturnsEmpty() + { + var builder = new TruncatingStringBuilder(); + builder.Append("test"); + builder.Dispose(); + + Assert.Equal(string.Empty, builder.ToString()); + } +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/VerifyExtensions.cs b/test/OpenTelemetry.Instrumentation.Kusto.Tests/VerifyExtensions.cs new file mode 100644 index 0000000000..3f7b36a350 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/VerifyExtensions.cs @@ -0,0 +1,15 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.Instrumentation.Kusto.Tests; + +internal static class VerifyExtensions +{ + public static SettingsTask ScrubHostname(this SettingsTask settings, string hostname) => + settings.ScrubLinesWithReplace(line => line.Replace(hostname, "Scrubbed")); + + public static SettingsTask ScrubPort(this SettingsTask settings, int port) => + settings + .ScrubLinesWithReplace(line => line.Replace(port.ToString(), "Scrubbed")) + .ScrubInstance(i => i == port); +} diff --git a/test/OpenTelemetry.Instrumentation.Kusto.Tests/kusto.Dockerfile b/test/OpenTelemetry.Instrumentation.Kusto.Tests/kusto.Dockerfile new file mode 100644 index 0000000000..17a0633c7c --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Kusto.Tests/kusto.Dockerfile @@ -0,0 +1 @@ +FROM mcr.microsoft.com/azuredataexplorer/kustainer-linux:latest@sha256:3d4f4f331fa5d7fe99ee42a0d6afd9caf53629cbcbcb0e977a6d3ba9730381da