Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="$(MicrosoftBclAsyncInterfacesPackageVersion)" />
<PackageVersion Include="Microsoft.Build.NuGetSdkResolver" Version="$(MicrosoftBuildNuGetSdkResolverPackageVersion)" />
<PackageVersion Include="Microsoft.Build.TaskAuthoring.Analyzer" Version="$(MicrosoftBuildTaskAuthoringAnalyzerPackageVersion)" />
<PackageVersion Include="OpenTelemetry" Version="$(OpenTelemetryDotnetPackageVersion)" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="$(OpenTelemetryDotnetPackageVersion)" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="$(OpenTelemetryDotnetPackageVersion)" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="$(OpenTelemetryInstrumentationHttpPackageVersion)" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="$(OpenTelemetryDotnetContribPackageVersion)" />
<PackageVersion Include="OpenTelemetry.PersistentStorage.FileSystem" Version="$(OpenTelemetryPersistentStoragePackageVersion)" />
<!-- roslyn dependencies -->
<PackageVersion Include="Microsoft.CodeAnalysis" Version="$(MicrosoftCodeAnalysisPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzer.Testing" Version="$(MicrosoftCodeAnalysisAnalyzerTestingVersion)" />
Expand Down
39 changes: 39 additions & 0 deletions documentation/project-docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ The .NET SDK telemetry can be disabled using the following environment variable:
- Default: `false` (messages are displayed)
- Note: This flag does not affect telemetry collection itself

- **`DOTNET_CLI_TELEMETRY_DISABLE_TRACE_EXPORT`**: Set to `1`, `true`, or `yes` to disable exporting trace
telemetry to Azure Monitor (both persistence and upload). Metrics/OTLP export and the local disk log are
unaffected. This does not disable telemetry collection itself — use `DOTNET_CLI_TELEMETRY_OPTOUT` for that.

- **`DOTNET_CLI_TELEMETRY_STORAGE_PATH`**: Overrides the directory used to persist trace telemetry before it is
uploaded (see [Telemetry Delivery](#telemetry-delivery)). Defaults to `TelemetryStorageService` under the .NET
user profile folder (e.g. `~/.dotnet/TelemetryStorageService`).

- **`DOTNET_CLI_TELEMETRY_LOG_PATH`**: When set, trace telemetry activities are also written to this path as a
local JSON log (used for diagnostics and testing). This does not affect Azure Monitor delivery.

### Telemetry Configuration

- **Default Behavior**:
Expand All @@ -57,6 +68,34 @@ In addition to the default Application Insights exporter, the SDK can also expor

When the OTLP exporter is enabled, all standard OpenTelemetry OTLP environment variables (endpoint, protocol, headers, timeout, etc.) are honored by the OpenTelemetry SDK's `OtlpExporterOptions` to configure the export destination.

### Telemetry Delivery

The .NET CLI is a short-lived process, which makes the standard "buffer in memory, POST on shutdown" delivery
model used by long-running services unreliable — a command frequently exits before an HTTP request to Azure
Monitor can complete. To make delivery robust, trace telemetry is delivered using a **persist-then-drain**
pipeline:

1. **Persist (in the current process).** As each activity (span) ends, it is mapped to the Application Insights
wire format and written synchronously to durable on-disk storage under the telemetry storage directory (see
`DOTNET_CLI_TELEMETRY_STORAGE_PATH`). Local disk writes are fast and reliable, so telemetry is captured before
the process exits.
2. **Drain (in the background).** On startup, a background worker leases previously persisted payloads and POSTs
them to the Azure Monitor ingestion endpoint. Successfully delivered payloads are deleted; failed uploads are
retained and retried on a later invocation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The exception to this is in CI, where drain should happen at the end of the application but with a timeout and a hook directly onto the POST to exit early.

3. **CI exception.** In CI environments (detected automatically), the CLI uses the standard Azure Monitor exporter
and calls `Shutdown` with a bounded timeout (default 20 seconds, configurable via
`DOTNET_CLI_TELEMETRY_SHUTDOWN_TIMEOUT_MS`) at the end of the process. This ensures the full export pipeline —
including inflight HTTP POSTs — completes before exit, since there is no subsequent invocation to drain
persisted telemetry. If the timeout expires, remaining data is abandoned.

Because a command usually exits before it can upload its *own* telemetry, that data is delivered by a subsequent
CLI invocation. This means Azure Monitor delivery is *eventually consistent across invocations* rather than
guaranteed within a single run. The background drain never blocks or delays command execution, and all upload
errors are swallowed so telemetry never affects the CLI.

This pipeline only applies to **trace** telemetry sent to Azure Monitor. Metrics, OTLP export, and the local disk
log are unaffected. Set `DOTNET_CLI_TELEMETRY_DISABLE_TRACE_EXPORT` to disable it.

- **First Time Use**: Telemetry is only collected after the first-time-use notice has been shown and accepted (tracked via sentinel file)

- **Event Namespace**: All telemetry events are automatically prefixed with `dotnet/cli/`
Expand Down
1 change: 1 addition & 0 deletions eng/Versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
<OpenTelemetryDotnetPackageVersion>1.16.0</OpenTelemetryDotnetPackageVersion>
<OpenTelemetryDotnetContribPackageVersion>1.15.1</OpenTelemetryDotnetContribPackageVersion>
<OpenTelemetryInstrumentationHttpPackageVersion>1.16.0</OpenTelemetryInstrumentationHttpPackageVersion>
<OpenTelemetryPersistentStoragePackageVersion>1.1.0</OpenTelemetryPersistentStoragePackageVersion>
</PropertyGroup>

<PropertyGroup Label="MicroBuild.Core version">
Expand Down
2 changes: 2 additions & 0 deletions sdk.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
<Project Path="src/Cli/Microsoft.DotNet.Cli.CommandLine/Microsoft.DotNet.Cli.CommandLine.csproj" />
<Project Path="src/Cli/Microsoft.DotNet.Cli.CoreUtils/Microsoft.DotNet.Cli.CoreUtils.csproj" />
<Project Path="src/Cli/Microsoft.DotNet.Cli.Definitions/Microsoft.DotNet.Cli.Definitions.csproj" />
<Project Path="src/Cli/Microsoft.DotNet.Cli.Telemetry/Microsoft.DotNet.Cli.Telemetry.csproj" />
<Project Path="src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj" />
<Project Path="src/Cli/Microsoft.DotNet.Configurer/Microsoft.DotNet.Configurer.csproj" />
<Project Path="src/Cli/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj" />
Expand Down Expand Up @@ -357,6 +358,7 @@
<Project Path="test/dotnet-watch.Tests/dotnet-watch.Tests.csproj" />
<Project Path="test/dotnet-aot.Tests/dotnet-aot.Tests.csproj" />
<Project Path="test/dotnet.Tests/dotnet.Tests.csproj" />
<Project Path="test/Microsoft.DotNet.Cli.Telemetry.Tests/Microsoft.DotNet.Cli.Telemetry.Tests.csproj" />
<Project Path="test/EndToEnd.Tests/EndToEnd.Tests.csproj" />
<Project Path="test/HelixTasks/HelixTasks.csproj" />
<Project Path="test/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.DotNet.Cli.Telemetry.Implementation;

/// <summary>
/// A minimal parser for the Application Insights connection string. We only need the
/// instrumentation key (stamped into every telemetry envelope's <c>iKey</c>) and the
/// ingestion endpoint (the base of the Breeze <c>/v2.1/track</c> upload URL).
/// </summary>
internal sealed record AzureMonitorConnectionString(string InstrumentationKey, Uri IngestionEndpoint)
{
private const string InstrumentationKeyName = "InstrumentationKey";
private const string IngestionEndpointName = "IngestionEndpoint";
private const string DefaultIngestionEndpoint = "https://dc.services.visualstudio.com/";

/// <summary>
/// The full URL telemetry is POSTed to, e.g.
/// <c>https://&lt;region&gt;.in.applicationinsights.azure.com/v2.1/track</c>.
/// </summary>
public Uri TrackUri { get; } = new Uri(IngestionEndpoint, "v2.1/track");

/// <summary>
/// Parses <paramref name="connectionString"/> into its instrumentation key and ingestion
/// endpoint. Returns <see langword="null"/> when the string is missing an instrumentation
/// key, which is the only field without a safe default.
/// </summary>
public static AzureMonitorConnectionString? Parse(string? connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
return null;
}

string? instrumentationKey = null;
var ingestionEndpoint = DefaultIngestionEndpoint;

foreach (var part in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var separatorIndex = part.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}

var key = part.Substring(0, separatorIndex).Trim();
var value = part.Substring(separatorIndex + 1).Trim();

if (key.Equals(InstrumentationKeyName, StringComparison.OrdinalIgnoreCase))
{
instrumentationKey = value;
}
else if (key.Equals(IngestionEndpointName, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value))
{
ingestionEndpoint = value;
}
}

if (string.IsNullOrWhiteSpace(instrumentationKey))
{
return null;
}

if (!Uri.TryCreate(EnsureTrailingSlash(ingestionEndpoint), UriKind.Absolute, out var endpointUri))
{
return null;
}

return new AzureMonitorConnectionString(instrumentationKey, endpointUri);
}

private static string EnsureTrailingSlash(string value)
=> value.EndsWith('/') ? value : value + "/";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Logs;

namespace Microsoft.DotNet.Cli.Telemetry.Implementation;

/// <summary>
/// Serializes a batch of OpenTelemetry <see cref="LogRecord"/> instances into the Application
/// Insights "Breeze" wire format (newline-delimited JSON of telemetry envelopes), exactly as
/// <c>Azure.Monitor.OpenTelemetry.Exporter</c> would produce via its internal
/// <c>LogsHelper.OtelToAzureMonitorLogs</c>:
/// <list type="bullet">
/// <item><description>A log record with an <see cref="LogRecord.Exception"/> becomes an
/// <c>ExceptionData</c> envelope.</description></item>
/// <item><description>Any other log record becomes a <c>MessageData</c> envelope.</description></item>
/// </list>
/// Both carry the mapped Application Insights <c>severityLevel</c>, the log attributes / scopes
/// as properties (plus <c>CategoryName</c>, <c>EventId</c>, and <c>EventName</c>), and the
/// operation id / parent id from the record's trace context. Feeding the Application Insights
/// <c>traces</c> table this way is what the Azure Monitor log exporter does, so existing
/// log-based dashboards keep working.
///
/// <para>This is a focused reimplementation covering the shapes a CLI actually emits; the more
/// advanced Azure-specific log conventions (custom events, availability results, and the
/// <c>microsoft.*</c> context attributes) are not special-cased and simply flow through as
/// ordinary message properties.</para>
/// </summary>
internal static class AzureMonitorLogSerializer
{
/// <summary>
/// Serializes <paramref name="batch"/> to NDJSON bytes. Returns <see langword="null"/>
/// when the batch produces no telemetry.
/// </summary>
public static byte[]? SerializeBatch(in Batch<LogRecord> batch, TelemetryResourceContext resource, string instrumentationKey)
{
using var stream = new MemoryStream();
var wroteAny = false;

foreach (var logRecord in batch)
{
WriteLogRecord(stream, logRecord, resource, instrumentationKey);
wroteAny = true;
}

return wroteAny ? stream.ToArray() : null;
}

private static readonly Action<LogRecordScope, (Dictionary<string, string> Properties, HashSet<string> Seen)> AddScopeProperties =
static (scope, state) =>
{
foreach (var scopeItem in scope)
{
if (string.IsNullOrEmpty(scopeItem.Key) || scopeItem.Key == BreezeSchema.OriginalFormatKey)
{
continue;
}

if (BreezeWriter.TryFormatProperty(scopeItem, state.Seen, out var key, out var value))
{
state.Properties[key] = value;
}
}
};

private static void WriteLogRecord(Stream stream, LogRecord logRecord, TelemetryResourceContext resource, string instrumentationKey)
{
var seenKeys = new HashSet<string>(StringComparer.Ordinal);
var properties = new Dictionary<string, string>(StringComparer.Ordinal);

// Mirrors LogsHelper: the exception message wins, then the formatted message, then the
// raw "{OriginalFormat}" template if nothing else produced a message.
var message = logRecord.Exception?.Message ?? logRecord.FormattedMessage;

foreach (var attribute in logRecord.Attributes ?? [])
{
if (attribute.Key == BreezeSchema.OriginalFormatKey)
{
if (logRecord.Exception?.Message is not null)
{
// Keep the template as a property when the exception supplied the message.
if (attribute.Value is not null && seenKeys.Add(BreezeSchema.OriginalFormatProperty))
{
properties[BreezeSchema.OriginalFormatProperty] =
BreezeSchema.Truncate(attribute.Value.ToString(), BreezeSchema.KvpMaxValueLength) ?? "null";
}
}
else if (message is null)
{
message = attribute.Value?.ToString();
}

continue;
}

if (BreezeWriter.TryFormatProperty(attribute, seenKeys, out var key, out var value))
{
properties[key] = value;
}
}

logRecord.ForEachScope(AddScopeProperties, (properties, seenKeys));

if (!string.IsNullOrEmpty(logRecord.CategoryName) && seenKeys.Add(BreezeSchema.CategoryNameProperty))
{
properties[BreezeSchema.CategoryNameProperty] =
BreezeSchema.Truncate(logRecord.CategoryName, BreezeSchema.KvpMaxValueLength)!;
}

if (logRecord.EventId.Id != 0 && seenKeys.Add(BreezeSchema.EventIdProperty))
{
properties[BreezeSchema.EventIdProperty] = logRecord.EventId.Id.ToString(CultureInfo.InvariantCulture);
}

if (!string.IsNullOrEmpty(logRecord.EventId.Name) && seenKeys.Add(BreezeSchema.EventNameProperty))
{
properties[BreezeSchema.EventNameProperty] =
BreezeSchema.Truncate(logRecord.EventId.Name, BreezeSchema.KvpMaxValueLength)!;
}

var tags = BuildTags(logRecord, resource);
var severity = GetSeverityLevel(logRecord.LogLevel);
var propertyBag = properties.Count > 0 ? properties : null;
var time = BreezeWriter.FormatTime(logRecord.Timestamp);

if (logRecord.Exception is not null)
{
var envelope = new TelemetryEnvelope<ExceptionData>
{
Name = BreezeSchema.ExceptionEnvelopeName,
Time = time,
InstrumentationKey = instrumentationKey,
Tags = tags,
Data = new TelemetryData<ExceptionData>
{
BaseType = BreezeSchema.ExceptionDataType,
BaseData = BuildExceptionData(logRecord.Exception, severity, propertyBag),
},
};
BreezeWriter.WriteEnvelope(stream, envelope, TelemetryJsonContext.Default.ExceptionEnvelope);
}
else
{
var envelope = new TelemetryEnvelope<MessageData>
{
Name = BreezeSchema.MessageEnvelopeName,
Time = time,
InstrumentationKey = instrumentationKey,
Tags = tags,
Data = new TelemetryData<MessageData>
{
BaseType = BreezeSchema.MessageDataType,
BaseData = new MessageData
{
Message = BreezeSchema.Truncate(message, BreezeSchema.MessageMaxLength),
SeverityLevel = severity,
Properties = propertyBag,
},
},
};
BreezeWriter.WriteEnvelope(stream, envelope, TelemetryJsonContext.Default.MessageEnvelope);
}
}

private static ExceptionData BuildExceptionData(Exception exception, string severity, Dictionary<string, string>? properties)
{
var stack = exception.StackTrace;
var hasFullStack = stack is not null && stack.Length <= BreezeSchema.ExceptionStackMaxLength;

return new ExceptionData
{
SeverityLevel = severity,
Exceptions =
[
new ExceptionDetails
{
TypeName = BreezeSchema.Truncate(exception.GetType().FullName, BreezeSchema.ExceptionTypeNameMaxLength),
Message = BreezeSchema.Truncate(exception.Message, BreezeSchema.ExceptionMessageMaxLength),
HasFullStack = hasFullStack,
Stack = BreezeSchema.Truncate(stack, BreezeSchema.ExceptionStackMaxLength),
},
],
Properties = properties ?? [],
};
}

private static Dictionary<string, string> BuildTags(LogRecord logRecord, TelemetryResourceContext resource)
{
var tags = new Dictionary<string, string>(StringComparer.Ordinal);

// The OTel log record carries the ambient Activity's trace context so Azure Monitor can
// correlate the log row with the span it was emitted under.
if (logRecord.TraceId != default)
{
tags[BreezeSchema.OperationId] = logRecord.TraceId.ToHexString();
}
if (logRecord.SpanId != default)
{
tags[BreezeSchema.OperationParentId] = logRecord.SpanId.ToHexString();
}

BreezeWriter.AddResourceTags(tags, resource);
return tags;
}

private static string GetSeverityLevel(LogLevel logLevel) => logLevel switch
{
LogLevel.Critical => BreezeSchema.SeverityCritical,
LogLevel.Error => BreezeSchema.SeverityError,
LogLevel.Warning => BreezeSchema.SeverityWarning,
LogLevel.Information => BreezeSchema.SeverityInformation,
_ => BreezeSchema.SeverityVerbose,
};
}
Loading
Loading