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) |
+
+[](https://www.nuget.org/packages/OpenTelemetry.Instrumentation.Kusto)
+[](https://www.nuget.org/packages/OpenTelemetry.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