From e3b26470ca1306f3d64a352682937af5574a7623 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 16:04:36 -0700 Subject: [PATCH 01/34] Consolidate Roslyn telemetry onto one event sink and one metric sink Roslyn had eight loosely-coordinated abstractions over a single underlying transport. This collapses the duplicated ones and gives call sites two APIs instead of five. Deleted outright: - RoslynLogger, a byte-for-byte reimplementation of TelemetryLogger for the standalone LSP host. The host already constructed a TelemetryLogger and threw it away; it now registers it. This also closes a consent gap: RoslynLogger gated on "is a reporter configured" rather than IsOptedIn, so an opted-out session still built events and rented pooled property lists. - ITelemetryLog, ITelemetryBlockLog, ITelemetryLogProvider, TelemetryLogging, TelemetryLoggingInterpolatedStringHandler, VisualStudioTelemetryLog, TelemetryLogProvider, TimedTelemetryLogBlock -- four types and four hops to reach a call that is now direct. - AbstractAggregatingLog, AggregatingCounterLog, AggregatingHistogramLog, replaced by one VSMetricSink. - AggregateLogger.AddOrReplace/Remove and the five bespoke ways call sites used them. Composition is now fixed at startup and sinks are toggled through their own IsEnabled predicate, which structurally prevents a sink being registered twice and posting everything twice. - EmptyLogger, which had no callers. Renamed, no shim: ILogger -> IEventSink (it collided with Microsoft.Extensions.Logging.ILogger, which is why call sites needed aliases), AggregateLogger -> AggregateEventSink, Logger.SetLogger/GetLogger -> RoslynTelemetry.SetEventSink/GetEventSink/AddEventSink. Logger.Log and Logger.LogBlock remain as a forwarding shim so the 150+ existing call sites did not have to change here. They carry no [Obsolete] because the repo builds with warnings as errors. Aggregation now buckets by TelemetrySessionKey as well as instrument identity. The key is a constant today, but it means aggregation state is not keyed on the assumption of a single session -- daemon mode currently merges concurrent servers into one bucket, and one server exiting flushes and clears every other live server's partial data. The ~28 metric call sites moved to real tags. Bucket identity is preserved exactly: VSMetricSink builds its dimension key from tag values in declaration order, reproducing the compound strings call sites used to concatenate by hand. This also removes a per-call closure allocation on the LSP request path, where the property-setter lambda captured locals three times per request. Validation: Ide.slnf, Compilers.slnf, Razor.slnf and the CodeStyle package all build clean; new VSMetricSinkTests cover the exactly-one-post-per-flush, bucketing, naming and opt-out invariants; existing LSP and Razor telemetry tests pass unchanged. Razor's own aggregation is left in place -- see .github/memory/known-issues/razor.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .github/instructions/IDE.instructions.md | 8 + .github/memory/INDEX.md | 1 + .github/memory/known-issues/razor.md | 18 ++ .github/memory/telemetry.md | 116 +++++++++ .../SmartRenameViewModel_Telemetry.cs | 4 +- .../Core/Remote/SolutionChecksumUpdater.cs | 9 +- .../SuggestedActions/EditorSuggestedAction.cs | 2 +- .../SuggestedActionsSource_Async.cs | 8 +- .../CodeFixes/Service/CodeFixService.cs | 18 +- .../CodeRefactoringService.cs | 8 +- ...icAnalyzerService_GetDiagnosticsForSpan.cs | 2 +- .../Service/DocumentAnalysisExecutor.cs | 4 +- .../TelemetryReporterTests.cs | 9 - .../VSMetricSinkTests.cs | 109 ++++++++ .../Contracts/ITelemetryReporter.cs | 9 +- .../Logging/RoslynLogger.cs | 176 ------------- .../Program.cs | 6 +- .../LanguageServerTelemetryReporter.cs | 61 +---- .../Telemetry/VSCodeRequestTelemetryLogger.cs | 15 +- .../Telemetry/RequestTelemetryLogger.cs | 59 ++--- .../Core/Def/RoslynActivityLogger.cs | 54 ++-- .../AbstractWorkspaceTelemetryService.cs | 28 +- .../Core/Def/Telemetry/CodeMarkerLogger.cs | 2 +- .../Core/Def/Telemetry/FileLogger.cs | 2 +- .../Shared/AbstractAggregatingLog.cs | 129 --------- .../Telemetry/Shared/AggregatingCounterLog.cs | 37 --- .../Shared/AggregatingHistogramLog.cs | 62 ----- .../Telemetry/Shared/TelemetryLogProvider.cs | 106 -------- .../Def/Telemetry/Shared/TelemetryLogger.cs | 27 +- .../Shared/TimedTelemetryLogBlock.cs | 56 ---- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 245 ++++++++++++++++++ .../Shared/VisualStudioTelemetryLog.cs | 31 --- .../VisualStudioWorkspaceTelemetryService.cs | 33 ++- .../Services/ServiceHubServicesTests.cs | 4 +- ...xtViewWindowVerifierInProcessExtensions.cs | 14 +- .../Loggers/OutputWindowLogger.cs | 28 +- .../OptionPages/PerformanceLoggersPage.cs | 57 ++-- .../PerfMargin/PerfEventActivityLogger.cs | 2 +- .../PerfMargin/PerfMarginPanel.cs | 7 +- .../Core/Portable/CodeActions/CodeAction.cs | 2 +- .../Core/Portable/Log/AggregateLogger.cs | 156 ----------- .../Core/Portable/Log/EmptyLogger.cs | 30 --- src/Workspaces/Core/Portable/Log/EtwLogger.cs | 26 +- .../Log/RoslynTelemetry.Workspaces.cs | 77 ++++++ .../Core/Portable/Log/TraceLogger.cs | 17 +- .../Portable/Telemetry/ITelemetryBlockLog.cs | 19 -- .../Core/Portable/Telemetry/ITelemetryLog.cs | 15 -- .../Telemetry/ITelemetryLogProvider.cs | 35 --- .../Portable/Telemetry/TelemetryLogging.cs | 149 ----------- ...lemetryLoggingInterpolatedStringHandler.cs | 25 -- .../MEF/UseExportProviderAttribute.cs | 2 +- .../RemoteAssetSynchronizationService.cs | 9 +- .../PerformanceTrackerService.cs | 4 +- .../RemoteProcessTelemetryService.cs | 21 +- .../RemoteWorkspaceTelemetryService.cs | 27 +- .../Core/CompilerExtensions.projitems | 12 +- .../Compiler/Core/Log/AggregateEventSink.cs | 91 +++++++ .../Core/Log/{ILogger.cs => IEventSink.cs} | 17 +- .../Compiler/Core/Log/IMetricSink.cs | 42 +++ .../Compiler/Core/Log/Logger.cs | 244 ++++------------- ...ogBlock.cs => RoslynTelemetry.LogBlock.cs} | 22 +- .../Core/Log/RoslynTelemetry.Metrics.cs | 179 +++++++++++++ .../Compiler/Core/Log/RoslynTelemetry.cs | 239 +++++++++++++++++ .../Compiler/Core/Log/TelemetryNaming.cs | 37 +++ .../Compiler/Core/Log/TelemetrySessionKey.cs | 45 ++++ 65 files changed, 1579 insertions(+), 1529 deletions(-) create mode 100644 .github/memory/telemetry.md create mode 100644 src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs delete mode 100644 src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Logging/RoslynLogger.cs delete mode 100644 src/VisualStudio/Core/Def/Telemetry/Shared/AbstractAggregatingLog.cs delete mode 100644 src/VisualStudio/Core/Def/Telemetry/Shared/AggregatingCounterLog.cs delete mode 100644 src/VisualStudio/Core/Def/Telemetry/Shared/AggregatingHistogramLog.cs delete mode 100644 src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogProvider.cs delete mode 100644 src/VisualStudio/Core/Def/Telemetry/Shared/TimedTelemetryLogBlock.cs create mode 100644 src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs delete mode 100644 src/VisualStudio/Core/Def/Telemetry/Shared/VisualStudioTelemetryLog.cs delete mode 100644 src/Workspaces/Core/Portable/Log/AggregateLogger.cs delete mode 100644 src/Workspaces/Core/Portable/Log/EmptyLogger.cs create mode 100644 src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs delete mode 100644 src/Workspaces/Core/Portable/Telemetry/ITelemetryBlockLog.cs delete mode 100644 src/Workspaces/Core/Portable/Telemetry/ITelemetryLog.cs delete mode 100644 src/Workspaces/Core/Portable/Telemetry/ITelemetryLogProvider.cs delete mode 100644 src/Workspaces/Core/Portable/Telemetry/TelemetryLogging.cs delete mode 100644 src/Workspaces/Core/Portable/Telemetry/TelemetryLoggingInterpolatedStringHandler.cs create mode 100644 src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs rename src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/{ILogger.cs => IEventSink.cs} (55%) create mode 100644 src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs rename src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/{Logger.LogBlock.cs => RoslynTelemetry.LogBlock.cs} (73%) create mode 100644 src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs create mode 100644 src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs create mode 100644 src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs create mode 100644 src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs diff --git a/.github/instructions/IDE.instructions.md b/.github/instructions/IDE.instructions.md index f6a1e030f6da1..1eefc301a2649 100644 --- a/.github/instructions/IDE.instructions.md +++ b/.github/instructions/IDE.instructions.md @@ -59,6 +59,14 @@ public MyService(IDependency dependency) { } - ServiceHub components live under `src/Workspaces/Remote/` and have special deployment considerations for .NET Core vs .NET Framework — keep both targets in mind when changing remote services +## Telemetry & Logging + +- Record events and scopes with `RoslynTelemetry.Log` / `RoslynTelemetry.LogBlock`; record aggregated measurements with `RoslynTelemetry.Count` / `.Record` / `.RecordBlockTime`, passing dimensions as tags rather than concatenating them into the metric name. +- `Logger.Log` / `Logger.LogBlock` are a forwarding shim kept so existing call sites did not all have to change at once. Do not add new call sites to them. +- Sink composition is fixed at host startup. To turn a sink on or off, update its `IsEnabled` predicate on the already-composed instance (`UpdatePredicate`); never add or remove a sink from the list, which is how a sink ends up registered twice and posting everything twice. +- Consent is a sink-level gate (`session.IsOptedIn`), never a call-site check. +- Full detail — sink contracts, `VSMetricSink` aggregation invariants, per-host wiring — is in `.github/memory/telemetry.md`. Read it before changing anything under `Internal.Log` or `src/VisualStudio/Core/Def/Telemetry/`. + ## Key Development Patterns ### TestAccessor Pattern diff --git a/.github/memory/INDEX.md b/.github/memory/INDEX.md index 910990397e16c..75b3c66671c44 100644 --- a/.github/memory/INDEX.md +++ b/.github/memory/INDEX.md @@ -17,6 +17,7 @@ This is the loading map for the agent knowledge base under `.github/memory/`. ** | **`API_MAP.md`** | Build/test entry points & PublicAPI tracking | When changing build, tests, or public APIs | | **`KNOWN_ISSUES.md`** | Repo-wide / cross-cutting quirks & workarounds | Always for code review; unfamiliar areas | | **`TESTING_STRATEGY.md`** | Test layout, shared authoring conventions & how to run tests | When writing tests or debugging test failures | +| **`telemetry.md`** | Telemetry & logging: event/metric sinks, composition, consent, per-host wiring | When adding telemetry, changing logging, or touching `Internal.Log` | ## Layer-specific knowledge diff --git a/.github/memory/known-issues/razor.md b/.github/memory/known-issues/razor.md index 2405b2e81d695..90c0528151441 100644 --- a/.github/memory/known-issues/razor.md +++ b/.github/memory/known-issues/razor.md @@ -76,3 +76,21 @@ the project may be built without being independently published. `ComputeResolvedFilesToPublishList`. Set `PostprocessAssembly=true` on managed assemblies so ReadyToRun replaces them with the RID-specific output, and preserve `RecursiveDir` in satellite-resource `RelativePath` metadata. + +## Razor still has its own telemetry aggregation, duplicating `VSMetricSink` + +**Affected area:** `Microsoft.VisualStudio.LanguageServices.Razor/Telemetry/` +**Description:** Razor's `AggregatingTelemetryLog` / `AggregatingTelemetryLogManager`, plus the request +`Counter` nested in `TelemetryReporter.TelemetrySessionManager`, wrap the same +`Microsoft.VisualStudio.Telemetry.Metrics` surface that Roslyn's `VSMetricSink` does. The assembly graph +does *not* prevent sharing: `Microsoft.CodeAnalysis.Remote.ServiceHub` already links +`src/VisualStudio/Core/Def/Telemetry/Shared/*.cs`, and `Microsoft.CodeAnalysis.Remote.Razor` already has a +`ProjectReference` to it, so an `InternalsVisibleTo` grant is all that is missing for the VS and OOP hosts. + +**What actually blocks it** is the VS Code host. `VSCodeTelemetryReporter` owns no `TelemetrySession`; it +overrides `Report` / `ReportMetric` to forward flattened events through +`ILanguageServerTelemetryReporterWrapper` into Roslyn's reporter, and the dependency runs Roslyn → Razor, +so Razor's VS Code extension cannot reach a Roslyn `IMetricSink` instance. Consolidating requires growing +that wrapper interface with `Count`/`Record` methods, which changes the emitted shape of Razor's VS Code +metrics from flattened events on Roslyn's session to metric events — a Razor-owned telemetry change that +needs its own dashboard validation. diff --git a/.github/memory/telemetry.md b/.github/memory/telemetry.md new file mode 100644 index 0000000000000..155e03ae71c1b --- /dev/null +++ b/.github/memory/telemetry.md @@ -0,0 +1,116 @@ +--- +coverage: Roslyn's telemetry and logging architecture - event sinks, metric sinks, host composition, and how each host (VS, OOP, standalone LSP, build server, tests, Razor) wires them up +--- + +# Telemetry & Logging + +## The two call-site APIs + +Everything Roslyn records goes through one of three families. Pick by *what you are recording*, not by +which host you are in. + +| Recording | Call | Available in | +|---|---|---| +| A discrete event or a timed scope | `RoslynTelemetry.Log(FunctionId, ...)` / `RoslynTelemetry.LogBlock(FunctionId, ...)` | Every layer, including the CodeStyle packages | +| An aggregated measurement | `RoslynTelemetry.Count(FunctionId, metricName, delta, tags...)` / `.Record(...)` / `.RecordBlockTime(...)` | Every layer | +| A reliability failure | `FatalError.ReportAndCatch(...)` and friends | Every layer | + +All live in `Microsoft.CodeAnalysis.Internal.Log` +(`src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/`), which is linked source compiled into +~15 assemblies. With no sink configured every method is a cheap no-op — that is what the build server, +the CodeStyle packages, and most tests rely on. + +`Logger.Log` / `Logger.LogBlock` still exist as a thin forwarding shim onto `RoslynTelemetry` so that the +150+ existing call sites did not have to change in one go. **New code should call `RoslynTelemetry` +directly.** The shim carries no `[Obsolete]` because the repository builds with warnings as errors. + +## Sinks + +``` +call site ──► RoslynTelemetry ──┬─► IEventSink (events + scopes) + └─► IMetricSink (aggregated measurements) +``` + +- **`IEventSink`** — `IsEnabled(FunctionId)`, `Log`, `LogBlockStart`, `LogBlockEnd`. `IsEnabled` is + consulted *before* any `LogMessage` is constructed, so a disabled sink costs nothing. This is where + both consent (telemetry sinks return `session.IsOptedIn`) and opt-in enablement (diagnostic sinks + consult a predicate) live. +- **`IMetricSink`** — `Count`, `Record`, `Flush`. Deliberately free of any telemetry-backend or BCL + metrics type so it can live in the dependency-minimal shared layer, and keyed by a plain + `string eventName` rather than `FunctionId` so Razor can share the implementation. + `FunctionId` → event-name mapping happens one level up, in `TelemetryNaming`. + +`TelemetryNaming` is the only place the `vs/ide/vbcs/` and `vs.ide.vbcs.` conventions appear. + +## Composition is fixed; enablement is not + +A host builds its sink list **once**, at startup, via `AggregateEventSink.Create(...)`, and never mutates +it. Turning a sink off means its own `IsEnabled` returns false — not removing it from the list. This is +load-bearing: a sink registered twice posts every event twice, and the previous predicate-based +add/replace/remove API made that easy to do by accident. + +- `EtwLogger`, `TraceLogger`, `OutputWindowLogger` expose `UpdatePredicate(...)`; the Performance Loggers + options page refreshes the **composed instances** through + `VisualStudioWorkspaceTelemetryService.UpdateDiagnosticSinkEnablement` (and its OOP mirror on + `RemoteWorkspaceTelemetryService`). +- `RoslynActivityLogger.Sink` is composed once and holds an `ImmutableArray`; adding and + removing a `TraceSource` mutates that set, not the sink list. +- Sinks that live in assemblies the composition root cannot reference (the diagnostics tool window VSIX, + integration tests) attach themselves once with `RoslynTelemetry.AddEventSink` and are thereafter + controlled by their predicate. They never detach. + +## Aggregation: `VSMetricSink` + +`src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs` is the single aggregating implementation, +backed by VS Telemetry's `IMeter`/`ICounter`/`IHistogram`. The `Shared` folder is linked into +`Microsoft.VisualStudio.LanguageServices`, `Microsoft.CodeAnalysis.Remote.ServiceHub`, and +`Microsoft.CodeAnalysis.LanguageServer`, so all three hosts compile their own copy. + +Three properties worth knowing before changing it: + +1. **Buckets are keyed by `(TelemetrySessionKey, eventName, metricName, dimensionKey)`.** The session key + is a constant today; it exists so aggregation state is never keyed on the assumption of a single + session. A process running several language servers (daemon mode) needs each server's measurements + bucketed and posted separately, and retrofitting that later would be a rewrite of the aggregation + rather than a configuration change. +2. **`dimensionKey` is the tag values concatenated in declaration order.** This reproduces the compound + string call sites used to build by hand (`server.method.language`), so migrating a call site to tags + does not change which measurements aggregate together. +3. **`Flush()` is global and clears everything.** It posts each bucket to the session that produced it. + Clearing on flush is also what keeps a long-lived process from accruing buckets for ended sessions. + The two-level lock (`_flushLock` plus a per-aggregation lock) is required — see + https://github.com/dotnet/roslyn/pull/71606, where concurrent `PostMetricEvent` calls for one + instrument were crashing. + +`VSMetricSink.IMetricPoster` is the per-session seam that lets tests assert exactly how many events a +flush posts without standing up a real, opted-in `TelemetrySession`. + +## Per-host wiring + +| Host | Entry point | Sinks | +|---|---|---| +| **Visual Studio** | `VisualStudioWorkspaceTelemetryService.CreateLogger` via `AbstractWorkspaceTelemetryService.InitializeTelemetrySession` | `CodeMarkerLogger`, `EtwLogger`, `TraceLogger`, `RoslynActivityLogger.Sink`, `TelemetryLogger`, `FileLogger` + `VSMetricSink` | +| **ServiceHub / OOP** | `RemoteWorkspaceTelemetryService.CreateLogger`; VS serializes its session and RPCs `InitializeTelemetrySessionAsync` | `EtwLogger`, `TraceLogger`, `TelemetryLogger` + `VSMetricSink` | +| **Standalone LSP** | `LanguageServerTelemetryReporter.InitializeSession`, called from `Program.cs` | `TelemetryLogger` + `VSMetricSink` | +| **VBCSCompiler** | `BuildServerController.RunServer` | none — uses `ICompilerServerLogger` only, by design | +| **Tests** | `UseExportProviderAttribute` resets sinks after every test | none by default | + +`AbstractWorkspaceTelemetryService` also starts the 30-minute periodic `RoslynTelemetry.Flush()`. Shutdown +paths flush explicitly as well, because a host can exit too abruptly for the timer to run +(https://github.com/dotnet/roslyn/pull/73287). + +## Consent + +Consent is a **sink-level, non-bypassable** gate, never a call-site decision. `TelemetryLogger.IsEnabled` +returns `session.IsOptedIn`, and `VSMetricSink` checks `IMetricPoster.IsOptedIn` before building any +aggregation. Because `RoslynTelemetry` consults `IEventSink.IsEnabled` before constructing a +`LogMessage`, an opted-out session allocates nothing at all +(https://github.com/dotnet/roslyn/pull/52484). + +## Razor + +Razor keeps its own call-site facade (`Microsoft.CodeAnalysis.Razor.Telemetry.ITelemetryReporter`), which +is already tag-shaped (`Property` is a name/value pair and the overloads are `ReadOnlySpan`). +It still has its own aggregation implementation (`AggregatingTelemetryLog`, +`AggregatingTelemetryLogManager`, and the request `Counter` inside `TelemetryReporter`), which duplicates +`VSMetricSink`. Consolidating it is tracked separately — see `.github/memory/known-issues/razor.md`. diff --git a/src/EditorFeatures/Core/InlineRename/UI/SmartRename/SmartRenameViewModel_Telemetry.cs b/src/EditorFeatures/Core/InlineRename/UI/SmartRename/SmartRenameViewModel_Telemetry.cs index 9959e3308b7dd..37f769b0999cf 100644 --- a/src/EditorFeatures/Core/InlineRename/UI/SmartRename/SmartRenameViewModel_Telemetry.cs +++ b/src/EditorFeatures/Core/InlineRename/UI/SmartRename/SmartRenameViewModel_Telemetry.cs @@ -45,7 +45,7 @@ private void PostTelemetry(bool isCommit) if (_suggestionsPanelTelemetry is not null) { RoslynDebug.Assert(_suggestionsDropdownTelemetry is null); - TelemetryLogging.Log(FunctionId.Copilot_Rename, KeyValueLogMessage.Create(m => + RoslynTelemetry.Log(FunctionId.Copilot_Rename, KeyValueLogMessage.Create(m => { m[nameof(isCommit)] = isCommit; m["UseSuggestionsPanel"] = true; @@ -61,7 +61,7 @@ private void PostTelemetry(bool isCommit) else { RoslynDebug.Assert(_suggestionsDropdownTelemetry is not null); - TelemetryLogging.Log(FunctionId.Copilot_Rename, KeyValueLogMessage.Create(m => + RoslynTelemetry.Log(FunctionId.Copilot_Rename, KeyValueLogMessage.Create(m => { m[nameof(isCommit)] = isCommit; m["UseDropDown"] = true; diff --git a/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs b/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs index d754b545a6101..51104bcb5a867 100644 --- a/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs +++ b/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs @@ -192,14 +192,7 @@ private async Task DispatchSynchronizeTextChangesAsync( // Update aggregated telemetry with success status of sending the synchronization data. var metricName = wasSynchronized.Value ? SynchronizeTextChangesStatusSucceededMetricName : SynchronizeTextChangesStatusFailedMetricName; - var keyName = wasSynchronized.Value ? SynchronizeTextChangesStatusSucceededKeyName : SynchronizeTextChangesStatusFailedKeyName; - TelemetryLogging.LogAggregatedCounter(FunctionId.ChecksumUpdater_SynchronizeTextChangesStatus, KeyValueLogMessage.Create(static (m, args) => - { - var (keyName, metricName) = args; - m[TelemetryLogging.KeyName] = keyName; - m[TelemetryLogging.KeyValue] = 1L; - m[TelemetryLogging.KeyMetricName] = metricName; - }, (keyName, metricName))); + RoslynTelemetry.Count(FunctionId.ChecksumUpdater_SynchronizeTextChangesStatus, metricName, 1); return; diff --git a/src/EditorFeatures/Core/Suggestions/SuggestedActions/EditorSuggestedAction.cs b/src/EditorFeatures/Core/Suggestions/SuggestedActions/EditorSuggestedAction.cs index 52203733b741d..2c16d0f154d9b 100644 --- a/src/EditorFeatures/Core/Suggestions/SuggestedActions/EditorSuggestedAction.cs +++ b/src/EditorFeatures/Core/Suggestions/SuggestedActions/EditorSuggestedAction.cs @@ -101,7 +101,7 @@ private async Task InvokeAsync() { try { - using var _ = TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.SuggestedAction_Application_Summary, $"Total"); + using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.SuggestedAction_Application_Summary, $"Total"); using var token = SourceProvider.OperationListener.BeginAsyncOperation($"{nameof(EditorSuggestedAction)}.{nameof(Invoke)}"); using var context = SourceProvider.UIThreadOperationExecutor.BeginExecute( diff --git a/src/EditorFeatures/Core/Suggestions/SuggestedActionsSource_Async.cs b/src/EditorFeatures/Core/Suggestions/SuggestedActionsSource_Async.cs index 5a24330764a43..4ac3c48dd1789 100644 --- a/src/EditorFeatures/Core/Suggestions/SuggestedActionsSource_Async.cs +++ b/src/EditorFeatures/Core/Suggestions/SuggestedActionsSource_Async.cs @@ -104,14 +104,14 @@ await document.Project.Solution.Services.GetRequiredService> GetCodeFixesAsync() { - using var _ = TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.SuggestedAction_Summary, $"Total.Pri{priority.GetPriorityInt()}.{nameof(GetCodeFixesAsync)}"); + using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.SuggestedAction_Summary, $"Total.Pri{priority.GetPriorityInt()}.{nameof(GetCodeFixesAsync)}"); if (owner._codeFixService == null || !supportsFeatureService.SupportsCodeFixes(target.SubjectBuffer) || @@ -235,7 +235,7 @@ await document.Project.Solution.Services.GetRequiredService> GetRefactoringsAsync() { - using var _ = TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.SuggestedAction_Summary, $"Total.Pri{priority.GetPriorityInt()}.{nameof(GetRefactoringsAsync)}"); + using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.SuggestedAction_Summary, $"Total.Pri{priority.GetPriorityInt()}.{nameof(GetRefactoringsAsync)}"); if (!selection.HasValue) { diff --git a/src/Features/Core/Portable/CodeFixes/Service/CodeFixService.cs b/src/Features/Core/Portable/CodeFixes/Service/CodeFixService.cs index 231e59c8e2aed..94c468afe876f 100644 --- a/src/Features/Core/Portable/CodeFixes/Service/CodeFixService.cs +++ b/src/Features/Core/Portable/CodeFixes/Service/CodeFixService.cs @@ -95,11 +95,11 @@ private DiagnosticIdFilter GetShouldIncludeDiagnosticPredicate( public async Task GetMostSevereFixAsync( TextDocument document, TextSpan range, CodeActionRequestPriority? priority, CancellationToken cancellationToken) { - using var _ = TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}.{nameof(GetMostSevereFixAsync)}"); + using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}.{nameof(GetMostSevereFixAsync)}"); ImmutableArray allDiagnostics; - using (TelemetryLogging.LogBlockTimeAggregatedHistogram( + using (RoslynTelemetry.RecordBlockTime( FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}.{nameof(GetMostSevereFixAsync)}.{nameof(IDiagnosticAnalyzerService.GetDiagnosticsForSpanAsync)}")) { var service = document.Project.Solution.Services.GetRequiredService(); @@ -173,7 +173,7 @@ public async IAsyncEnumerable StreamFixesAsync( CodeActionRequestPriority? priority, [EnumeratorCancellation] CancellationToken cancellationToken) { - using var _ = TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}"); + using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}"); // We only need to compute suppression/configuration fixes when request priority is // 'CodeActionPriorityRequest.Lowest' or no priority was provided at all (so all providers should run). @@ -191,7 +191,7 @@ public async IAsyncEnumerable StreamFixesAsync( // user-invoked diagnostic requests, for example, user invoked Ctrl + Dot operation for lightbulb. ImmutableArray diagnostics; - using (TelemetryLogging.LogBlockTimeAggregatedHistogram( + using (RoslynTelemetry.RecordBlockTime( FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}.{nameof(IDiagnosticAnalyzerService.GetDiagnosticsForSpanAsync)}")) { var service = document.Project.Solution.Services.GetRequiredService(); @@ -286,7 +286,7 @@ private static SortedDictionary> ConvertToMap( { cancellationToken.ThrowIfCancellationRequested(); - using var _ = TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.CodeFix_Summary, $"{nameof(GetDocumentFixAllForIdInSpanAsync)}"); + using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.CodeFix_Summary, $"{nameof(GetDocumentFixAllForIdInSpanAsync)}"); ImmutableArray diagnostics; if (textSpan is null) @@ -295,7 +295,7 @@ private static SortedDictionary> ConvertToMap( textSpan = new TextSpan(0, text.Length); } - using (TelemetryLogging.LogBlockTimeAggregatedHistogram( + using (RoslynTelemetry.RecordBlockTime( FunctionId.CodeFix_Summary, $"{nameof(GetDocumentFixAllForIdInSpanAsync)}.{nameof(IDiagnosticAnalyzerService.GetDiagnosticsForSpanAsync)}")) { var service = document.Project.Solution.Services.GetRequiredService(); @@ -530,11 +530,11 @@ private async IAsyncEnumerable StreamFixesAsync( var logMessage = KeyValueLogMessage.Create(static (m, args) => { var (fixerName, document) = args; - m[TelemetryLogging.KeyName] = fixerName; - m[TelemetryLogging.KeyLanguageName] = document.Project.Language; + m[TelemetryKeys.Name] = fixerName; + m[TelemetryKeys.LanguageName] = document.Project.Language; }, (fixerName, document)); - using var _ = TelemetryLogging.LogBlockTime(FunctionId.CodeFix_Delay, logMessage, CodeFixTelemetryDelay); + using var _ = RoslynTelemetry.LogBlockTime(FunctionId.CodeFix_Delay, logMessage, CodeFixTelemetryDelay); var codeFixCollection = await TryGetFixesOrConfigurationsAsync( document, span, diagnostics, fixAllForInSpan, fixer, diff --git a/src/Features/Core/Portable/CodeRefactorings/CodeRefactoringService.cs b/src/Features/Core/Portable/CodeRefactorings/CodeRefactoringService.cs index 0d6bff46bed2e..5db5d1f9817d4 100644 --- a/src/Features/Core/Portable/CodeRefactorings/CodeRefactoringService.cs +++ b/src/Features/Core/Portable/CodeRefactorings/CodeRefactoringService.cs @@ -183,7 +183,7 @@ public async Task> GetRefactoringsAsync( CodeActionRequestPriority? priority, CancellationToken cancellationToken) { - using (TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.CodeRefactoring_Summary, $"Pri{priority.GetPriorityInt()}")) + using (RoslynTelemetry.RecordBlockTime(FunctionId.CodeRefactoring_Summary, $"Pri{priority.GetPriorityInt()}")) using (Logger.LogBlock(FunctionId.Refactoring_CodeRefactoringService_GetRefactoringsAsync, cancellationToken)) { using var _ = PooledDictionary.GetInstance(out var providerToIndex); @@ -208,12 +208,12 @@ public async Task> GetRefactoringsAsync( var logMessage = KeyValueLogMessage.Create(static (m, args) => { var (providerName, document) = args; - m[TelemetryLogging.KeyName] = providerName; - m[TelemetryLogging.KeyLanguageName] = document.Project.Language; + m[TelemetryKeys.Name] = providerName; + m[TelemetryKeys.LanguageName] = document.Project.Language; }, (providerName, document)); using (RoslynEventSource.LogInformationalBlock(FunctionId.Refactoring_CodeRefactoringService_GetRefactoringsAsync, providerName, cancellationToken)) - using (TelemetryLogging.LogBlockTime(FunctionId.CodeRefactoring_Delay, logMessage, CodeRefactoringTelemetryDelay)) + using (RoslynTelemetry.LogBlockTime(FunctionId.CodeRefactoring_Delay, logMessage, CodeRefactoringTelemetryDelay)) { var refactoring = await @this.GetRefactoringFromProviderAsync( document, state, provider, cancellationToken).ConfigureAwait(false); diff --git a/src/Features/Core/Portable/Diagnostics/Service/DiagnosticAnalyzerService_GetDiagnosticsForSpan.cs b/src/Features/Core/Portable/Diagnostics/Service/DiagnosticAnalyzerService_GetDiagnosticsForSpan.cs index 0460e90917d0e..fa8deaf1224ee 100644 --- a/src/Features/Core/Portable/Diagnostics/Service/DiagnosticAnalyzerService_GetDiagnosticsForSpan.cs +++ b/src/Features/Core/Portable/Diagnostics/Service/DiagnosticAnalyzerService_GetDiagnosticsForSpan.cs @@ -91,7 +91,7 @@ public async Task> GetDiagnosticsForSpanInProcess using var _2 = ArrayBuilder.GetInstance(out var semanticSpanBasedAnalyzers); using var _3 = ArrayBuilder.GetInstance(out var semanticDocumentBasedAnalyzers); - using var _4 = TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.RequestDiagnostics_Summary, $"Pri{priority.GetPriorityInt()}"); + using var _4 = RoslynTelemetry.RecordBlockTime(FunctionId.RequestDiagnostics_Summary, $"Pri{priority.GetPriorityInt()}"); foreach (var analyzer in analyzers) { diff --git a/src/Features/Core/Portable/Diagnostics/Service/DocumentAnalysisExecutor.cs b/src/Features/Core/Portable/Diagnostics/Service/DocumentAnalysisExecutor.cs index bde1b3f2d1f7c..9d2a2c92195ea 100644 --- a/src/Features/Core/Portable/Diagnostics/Service/DocumentAnalysisExecutor.cs +++ b/src/Features/Core/Portable/Diagnostics/Service/DocumentAnalysisExecutor.cs @@ -216,7 +216,7 @@ async ValueTask> GetSyntaxDiagnosticsInProcessAsy if (_lazySyntaxDiagnostics == null) { - using var _ = TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.RequestDiagnostics_Summary, $"{nameof(GetSyntaxDiagnosticsInProcessAsync)}.{nameof(GetAnalysisResultInProcessAsync)}"); + using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.RequestDiagnostics_Summary, $"{nameof(GetSyntaxDiagnosticsInProcessAsync)}.{nameof(GetAnalysisResultInProcessAsync)}"); var analysisScope = AnalysisScope.WithAnalyzers(_compilationBasedAnalyzersInAnalysisScope); var syntaxDiagnostics = await GetAnalysisResultInProcessAsync(analysisScope).ConfigureAwait(false); @@ -252,7 +252,7 @@ async ValueTask> GetSemanticDiagnosticsInProcessA if (_lazySemanticDiagnostics == null) { - using var _ = TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.RequestDiagnostics_Summary, $"{nameof(GetSemanticDiagnosticsInProcessAsync)}.{nameof(GetAnalysisResultInProcessAsync)}"); + using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.RequestDiagnostics_Summary, $"{nameof(GetSemanticDiagnosticsInProcessAsync)}.{nameof(GetAnalysisResultInProcessAsync)}"); var analysisScope = AnalysisScope.WithAnalyzers(_compilationBasedAnalyzersInAnalysisScope); var semanticDiagnostics = await GetAnalysisResultInProcessAsync(analysisScope).ConfigureAwait(false); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs index 01835aad3748b..7276717420989 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs @@ -39,15 +39,6 @@ public void TestVSTelemetryLoadedIntoDefaultAlc() Assert.Contains(AssemblyLoadContext.Default.Assemblies, a => a.GetName().Name == "Microsoft.VisualStudio.Telemetry"); } - [Fact] - public void TestBlockLogging() - { - using var service = CreateReporter(DefaultServerConfiguration); - service.InitializeSession("off", "test-session", isDefaultSession: false); - service.LogBlockStart(GetEventName(nameof(TestBlockLogging)), kind: 0, blockId: 0); - service.LogBlockEnd(blockId: 0, [], CancellationToken.None); - } - [Fact] public void TestLog() { diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs new file mode 100644 index 0000000000000..dc9a4991cb98a --- /dev/null +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable disable + +using System.Collections.Generic; +using Microsoft.CodeAnalysis.Telemetry; +using Microsoft.VisualStudio.Telemetry; +using Microsoft.VisualStudio.Telemetry.Metrics.Events; +using Xunit; + +namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests; + +/// +/// Guards the aggregation invariants the previous implementation learned the hard way: every recorded +/// measurement must be posted exactly once per flush - never dropped, never double-counted - and +/// measurements must land in the right bucket. +/// +public sealed class VSMetricSinkTests +{ + private sealed class RecordingPoster : VSMetricSink.IMetricPoster + { + public List Posted { get; } = []; + + /// + /// The telemetry events carried by , captured at post time because + /// TelemetryMetricEvent does not expose them. + /// + public List PostedEvents { get; } = []; + + public bool IsOptedIn { get; set; } = true; + + public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent) + { + Posted.Add(metricEvent); + PostedEvents.Add(telemetryEvent); + } + } + + [Fact] + public void RecordedMeasurementsArePostedExactlyOncePerFlush() + { + var poster = new RecordingPoster(); + var sink = new VSMetricSink(poster); + + sink.Count("vs/ide/vbcs/test/counter", "SucceededCount", 1, default); + sink.Count("vs/ide/vbcs/test/counter", "SucceededCount", 1, default); + sink.Record("vs/ide/vbcs/test/histogram", "Duration", 42, default); + + sink.Flush(); + + // Two distinct instruments -> exactly two events. Never zero (dropped), never more (double-counted). + Assert.Equal(2, poster.Posted.Count); + + // Flush also clears, so a second flush must not re-post anything. + poster.Posted.Clear(); + sink.Flush(); + Assert.Empty(poster.Posted); + } + + [Fact] + public void TagValuesDiscriminateBuckets() + { + var poster = new RecordingPoster(); + var sink = new VSMetricSink(poster); + + // Same event and metric, different tag values. These are the dimensions call sites used to + // concatenate by hand into a single compound bucket key. + sink.Record("vs/ide/vbcs/lsp/requestduration", "RequestDuration", 10, + new KeyValuePair[] { new("server", "Roslyn"), new("method", "textDocument/hover") }); + sink.Record("vs/ide/vbcs/lsp/requestduration", "RequestDuration", 20, + new KeyValuePair[] { new("server", "Roslyn"), new("method", "textDocument/completion") }); + sink.Record("vs/ide/vbcs/lsp/requestduration", "RequestDuration", 30, + new KeyValuePair[] { new("server", "Roslyn"), new("method", "textDocument/hover") }); + + sink.Flush(); + + Assert.Equal(2, poster.Posted.Count); + } + + [Fact] + public void EventAndPropertyNamesMatchThePreviousShape() + { + var poster = new RecordingPoster(); + var sink = new VSMetricSink(poster); + + sink.Count("vs/ide/vbcs/lsp/requestcounter", "SucceededCount", 1, + new KeyValuePair[] { new("server", "Roslyn") }); + + sink.Flush(); + + var posted = Assert.Single(poster.PostedEvents); + Assert.Equal("vs/ide/vbcs/lsp/requestcounter", posted.Name); + Assert.True(posted.Properties.ContainsKey("vs.ide.vbcs.lsp.requestcounter.server")); + } + + [Fact] + public void NothingIsRecordedForAnOptedOutSession() + { + var poster = new RecordingPoster { IsOptedIn = false }; + var sink = new VSMetricSink(poster); + + sink.Count("vs/ide/vbcs/test/counter", "SucceededCount", 1, default); + sink.Flush(); + + Assert.Empty(poster.Posted); + } +} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Contracts/ITelemetryReporter.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Contracts/ITelemetryReporter.cs index 37b4a0fbc0a5b..105a7de003f9e 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Contracts/ITelemetryReporter.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Contracts/ITelemetryReporter.cs @@ -7,7 +7,12 @@ namespace Microsoft.CodeAnalysis.Contracts.Telemetry; internal interface ITelemetryReporter : IDisposable { void InitializeSession(string telemetryLevel, string? sessionId, bool isDefaultSession); + + /// + /// Posts an already-named telemetry event with already-final property names. Used by the Razor + /// VS Code bridge (ILanguageServerTelemetryReporterWrapper), which owns no telemetry session + /// of its own and forwards through this reporter. Roslyn's own FunctionId-based + /// event pipeline does not go through here - it uses TelemetryLogger directly. + /// void Log(string name, List> properties); - void LogBlockStart(string eventName, int kind, int blockId); - void LogBlockEnd(int blockId, List> properties, CancellationToken cancellationToken); } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Logging/RoslynLogger.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Logging/RoslynLogger.cs deleted file mode 100644 index 0fe44e0dac3ae..0000000000000 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Logging/RoslynLogger.cs +++ /dev/null @@ -1,176 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using Microsoft.CodeAnalysis.Common; -using Microsoft.CodeAnalysis.Contracts.Telemetry; -using Microsoft.CodeAnalysis.ErrorReporting; -using Microsoft.CodeAnalysis.Internal.Log; -using Microsoft.CodeAnalysis.PooledObjects; - -namespace Microsoft.CodeAnalysis.LanguageServer.Logging; - -internal sealed class RoslynLogger : ILogger -{ - private static RoslynLogger? _instance; - private static readonly ConcurrentDictionary s_eventMap = []; - private static readonly ConcurrentDictionary<(FunctionId id, string name), string> s_propertyMap = []; - - private readonly ConcurrentDictionary _pendingScopes = new(concurrencyLevel: 2, capacity: 10); - private static ITelemetryReporter? _telemetryReporter; - private static readonly ObjectPool>> s_propertyPool = new(() => []); - - private RoslynLogger() - { - } - - public static void Initialize(ITelemetryReporter? reporter, string? telemetryLevel, string? sessionId) - { - Contract.ThrowIfTrue(_instance is not null); - - if (reporter is not null && telemetryLevel is not null) - { - reporter.InitializeSession(telemetryLevel, sessionId, isDefaultSession: true); - _telemetryReporter = reporter; - } - - _instance = new(); - - var currentLogger = Logger.GetLogger(); - if (currentLogger is null) - { - Logger.SetLogger(_instance); - } - else - { - Logger.SetLogger(AggregateLogger.Create(currentLogger, _instance)); - } - } - - public bool IsEnabled(FunctionId functionId) - => _telemetryReporter is not null; - - public void Log(FunctionId functionId, LogMessage logMessage) - { - if (IgnoreReporting(logMessage)) - { - return; - } - - using var pooledObject = s_propertyPool.GetPooledObject(); - var properties = pooledObject.Object; - - var name = GetEventName(functionId); - AddProperties(properties, functionId, logMessage, delta: null); - - try - { - _telemetryReporter.Log(name, properties); - } - catch - { - } - } - - public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int blockId, CancellationToken cancellationToken) - { - if (IgnoreReporting(logMessage)) - { - return; - } - - var eventName = GetEventName(functionId); - var kind = GetKind(logMessage); - - try - { - _telemetryReporter.LogBlockStart(eventName, (int)kind, blockId); - } - catch - { - } - } - - public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int blockId, int delta, CancellationToken cancellationToken) - { - if (IgnoreReporting(logMessage)) - { - return; - } - - using var pooledObject = s_propertyPool.GetPooledObject(); - var properties = pooledObject.Object; - - AddProperties(properties, functionId, logMessage, delta); - try - { - _telemetryReporter.LogBlockEnd(blockId, properties, cancellationToken); - } - catch - { - } - } - - public static void ShutdownAndReportSessionTelemetry() - { - if (_instance is null) - { - return; - } - - FeaturesSessionTelemetry.Report(); - - (var currentReporter, _telemetryReporter) = (_telemetryReporter, null); - currentReporter?.Dispose(); - _instance = null; - } - - [MemberNotNullWhen(false, nameof(_telemetryReporter))] - private static bool IgnoreReporting(LogMessage logMessage) - => _telemetryReporter is null || - logMessage.LogLevel < LogLevel.Information; - - private const string EventPrefix = "vs/ide/vbcs/"; - private const string PropertyPrefix = "vs.ide.vbcs."; - - private static string GetEventName(FunctionId id) - => s_eventMap.GetOrAdd(id, id => EventPrefix + GetTelemetryName(id, separator: '/')); - - private static string GetPropertyName(FunctionId id, string name) - => s_propertyMap.GetOrAdd((id, name), key => PropertyPrefix + GetTelemetryName(id, separator: '.') + "." + key.name.ToLowerInvariant()); - - private static string GetTelemetryName(FunctionId id, char separator) - => Enum.GetName(typeof(FunctionId), id)!.Replace('_', separator).ToLowerInvariant(); - - private static LogType GetKind(LogMessage logMessage) - => logMessage is KeyValueLogMessage kvLogMessage - ? kvLogMessage.Kind - : logMessage.LogLevel switch - { - >= LogLevel.Information => LogType.UserAction, - _ => LogType.Trace - }; - - private static void AddProperties(List> properties, FunctionId id, LogMessage logMessage, int? delta) - { - if (logMessage is KeyValueLogMessage kvLogMessage) - { - foreach (var (name, val) in kvLogMessage.Properties) - { - properties.Add(new(GetPropertyName(id, name), val)); - } - } - else - { - properties.Add(new(GetPropertyName(id, "Message"), logMessage.GetMessage())); - } - - if (delta.HasValue) - { - properties.Add(new(GetPropertyName(id, "Delta"), delta.Value)); - } - } -} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs index 964360b0f2313..26115ea2fe499 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs @@ -133,7 +133,7 @@ static async Task RunAsync(ServerConfiguration serverConfiguration, Cancell var telemetryReporter = telemetryLevel is not null ? exportProvider.GetExportedValue() : null; - RoslynLogger.Initialize(telemetryReporter, telemetryLevel, serverConfiguration.SessionId); + telemetryReporter?.InitializeSession(telemetryLevel!, serverConfiguration.SessionId, isDefaultSession: true); // Build the connection source for the configured mode. Single-server mode (stdio / connect-out pipe) yields // exactly one connection; daemon mode accepts many and manages its own idle timeout. Both run through the same @@ -199,8 +199,8 @@ serverConfiguration.ClientProcessId is int clientProcessId && } finally { - // After the LSP server shutdown, report session wide telemetry - RoslynLogger.ShutdownAndReportSessionTelemetry(); + // After the LSP server shutdown, report session wide telemetry and dispose the session. + telemetryReporter?.Dispose(); } return ServerExitCodes.Success; diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryReporter.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryReporter.cs index e493065252f29..08487df890e0b 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryReporter.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryReporter.cs @@ -2,13 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Collections.Concurrent; using System.Composition; using System.Diagnostics; using System.Text; +using Microsoft.CodeAnalysis.Common; using Microsoft.CodeAnalysis.Contracts.Telemetry; using Microsoft.CodeAnalysis.ErrorReporting; using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Telemetry; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.Telemetry; @@ -30,8 +31,6 @@ internal sealed class LanguageServerTelemetryReporter : ITelemetryReporter /// private const string VSCollectorApiKey = "f3e86b4023cc43f0be495508d51f588a-f70d0e59-0fb0-4473-9f19-b4024cc340be-7296"; - private static readonly ConcurrentDictionary s_pendingScopes = new(concurrencyLevel: 2, capacity: 10); - private readonly ServerConfiguration _serverConfiguration; private readonly ILogger _logger; private TelemetrySession? _telemetrySession; @@ -80,7 +79,10 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD _telemetrySession = session; - TelemetryLogger.Create(session, logDelta: false); + // Register the shared FunctionId-based event sink. Previously this instance was created and + // then discarded, with RoslynLogger -- an independent, byte-for-byte reimplementation of the + // same logic -- registered instead. + RoslynTelemetry.SetEventSink(AggregateEventSink.Create(RoslynTelemetry.GetEventSink(), TelemetryLogger.Create(session, logDelta: false))); FaultReporter.InitializeFatalErrorHandlers(); FaultReporter.IncludeServiceHubLogFiles = false; @@ -107,48 +109,17 @@ public void Log(string name, List> properties) _telemetrySession.PostEvent(telemetryEvent); } - public void LogBlockStart(string eventName, int kind, int blockId) - { - if (_telemetrySession is null) - { - return; - } - - s_pendingScopes[blockId] = kind switch - { - 0 => _telemetrySession.StartOperation(eventName), // LogType.Trace - 1 => _telemetrySession.StartUserTask(eventName), // LogType.UserAction - _ => new InvalidOperationException($"Unknown BlockStart kind: {kind}") - }; - } - - public void LogBlockEnd(int blockId, List> properties, CancellationToken cancellationToken) - { - if (!s_pendingScopes.TryRemove(blockId, out var scope)) - { - return; - } - - var endEvent = GetEndEvent(scope); - SetProperties(endEvent, properties); - - var result = cancellationToken.IsCancellationRequested ? TelemetryResult.UserCancel : TelemetryResult.Success; - - if (scope is TelemetryScope operation) - operation.End(result); - else if (scope is TelemetryScope userTask) - userTask.End(result); - else - throw new InvalidCastException($"Unexpected value for scope: {scope}"); - } - public void Dispose() { - // Ensure that we flush any pending telemetry *before* we dispose of the telemetry session. - TelemetryLogging.Flush(); + // Ensure that telemetry aggregated over this session is reported and flushed *before* we + // dispose of the telemetry session. + FeaturesSessionTelemetry.Report(); + RoslynTelemetry.Flush(); if (_telemetrySession is { } session) { + RoslynTelemetry.SetEventSink(null); + FaultReporter.UnregisterTelemetrySesssion(session); session.Dispose(); _telemetrySession = null; @@ -204,14 +175,6 @@ static string StringToJsonValue(string? value) } } - private static TelemetryEvent GetEndEvent(object scope) - => scope switch - { - TelemetryScope operation => operation.EndEvent, - TelemetryScope userTask => userTask.EndEvent, - _ => throw new InvalidCastException($"Unexpected value for scope: {scope}") - }; - private static void SetProperties(TelemetryEvent telemetryEvent, List> properties) { foreach (var property in properties) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs index 6300b2b0cb7db..5fb06ee2740e3 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs @@ -33,15 +33,10 @@ public static void ReportProjectLoadStarted() protected override void IncreaseFindDocumentCount(string workspaceCountMetricName) { - TelemetryLogging.LogAggregatedCounter(FunctionId.LSP_FindDocumentInWorkspace, KeyValueLogMessage.Create(m => - { - var projectsLoaded = s_initialProjectLoadCompleted; - m[TelemetryLogging.KeyName] = ServerTypeName + "." + workspaceCountMetricName + "." + projectsLoaded; - m[TelemetryLogging.KeyValue] = 1L; - m[TelemetryLogging.KeyMetricName] = workspaceCountMetricName; - m["server"] = ServerTypeName; - m["workspace"] = workspaceCountMetricName; - m["projectsLoaded"] = projectsLoaded; - })); + var projectsLoaded = s_initialProjectLoadCompleted; + RoslynTelemetry.Count(FunctionId.LSP_FindDocumentInWorkspace, workspaceCountMetricName, 1, + new("server", ServerTypeName), + new("workspace", workspaceCountMetricName), + new("projectsLoaded", projectsLoaded)); } } diff --git a/src/LanguageServer/Protocol/Handler/Telemetry/RequestTelemetryLogger.cs b/src/LanguageServer/Protocol/Handler/Telemetry/RequestTelemetryLogger.cs index 6f1e983d9d6c7..3b93f9f3bc960 100644 --- a/src/LanguageServer/Protocol/Handler/Telemetry/RequestTelemetryLogger.cs +++ b/src/LanguageServer/Protocol/Handler/Telemetry/RequestTelemetryLogger.cs @@ -32,27 +32,17 @@ public void UpdateFindDocumentTelemetryData(bool success, string? workspaceKind) protected virtual void IncreaseFindDocumentCount(string workspaceCounterMetricName) { - TelemetryLogging.LogAggregatedCounter(FunctionId.LSP_FindDocumentInWorkspace, KeyValueLogMessage.Create(m => - { - m[TelemetryLogging.KeyName] = ServerTypeName + "." + workspaceCounterMetricName; - m[TelemetryLogging.KeyValue] = 1L; - m[TelemetryLogging.KeyMetricName] = workspaceCounterMetricName; - m["server"] = ServerTypeName; - m["workspace"] = workspaceCounterMetricName; - })); + RoslynTelemetry.Count(FunctionId.LSP_FindDocumentInWorkspace, workspaceCounterMetricName, 1, + new("server", ServerTypeName), + new("workspace", workspaceCounterMetricName)); } public void UpdateUsedForkedSolutionCounter(bool usedForkedSolution) { var metricName = usedForkedSolution ? "ForkedCount" : "NonForkedCount"; - TelemetryLogging.LogAggregatedCounter(FunctionId.LSP_UsedForkedSolution, KeyValueLogMessage.Create(m => - { - m[TelemetryLogging.KeyName] = ServerTypeName + "." + metricName; - m[TelemetryLogging.KeyValue] = 1L; - m[TelemetryLogging.KeyMetricName] = metricName; - m["server"] = ServerTypeName; - m["usedForkedSolution"] = usedForkedSolution; - })); + RoslynTelemetry.Count(FunctionId.LSP_UsedForkedSolution, metricName, 1, + new("server", ServerTypeName), + new("usedForkedSolution", usedForkedSolution)); } public void UpdateTelemetryData( @@ -63,23 +53,13 @@ public void UpdateTelemetryData( Result result) { // Store the request time metrics per LSP method. - TelemetryLogging.LogAggregatedHistogram(FunctionId.LSP_TimeInQueue, KeyValueLogMessage.Create(m => - { - m[TelemetryLogging.KeyName] = ServerTypeName; - m[TelemetryLogging.KeyValue] = (long)queuedDuration.TotalMilliseconds; - m[TelemetryLogging.KeyMetricName] = "TimeInQueue"; - m["server"] = ServerTypeName; - })); + RoslynTelemetry.Record(FunctionId.LSP_TimeInQueue, "TimeInQueue", (long)queuedDuration.TotalMilliseconds, + new("server", ServerTypeName)); - TelemetryLogging.LogAggregatedHistogram(FunctionId.LSP_RequestDuration, KeyValueLogMessage.Create(m => - { - m[TelemetryLogging.KeyName] = ServerTypeName + "." + methodName + "." + language; - m[TelemetryLogging.KeyValue] = (long)requestDuration.TotalMilliseconds; - m[TelemetryLogging.KeyMetricName] = "RequestDuration"; - m["server"] = ServerTypeName; - m["method"] = methodName; - m["language"] = language; - })); + RoslynTelemetry.Record(FunctionId.LSP_RequestDuration, "RequestDuration", (long)requestDuration.TotalMilliseconds, + new("server", ServerTypeName), + new("method", methodName), + new("language", language)); var metricName = result switch { @@ -89,22 +69,17 @@ public void UpdateTelemetryData( _ => throw ExceptionUtilities.UnexpectedValue(result) }; - TelemetryLogging.LogAggregatedCounter(FunctionId.LSP_RequestCounter, KeyValueLogMessage.Create(m => - { - m[TelemetryLogging.KeyName] = ServerTypeName + "." + methodName + "." + language + "." + metricName; - m[TelemetryLogging.KeyValue] = 1L; - m[TelemetryLogging.KeyMetricName] = metricName; - m["server"] = ServerTypeName; - m["method"] = methodName; - m["language"] = language; - })); + RoslynTelemetry.Count(FunctionId.LSP_RequestCounter, metricName, 1, + new("server", ServerTypeName), + new("method", methodName), + new("language", language)); } public void Dispose() { // Ensure that telemetry logged for this server instance is flushed before potentially creating a new instance. // This is also called on disposal of the telemetry session, but will no-op if already flushed. - TelemetryLogging.Flush(); + RoslynTelemetry.Flush(); } internal enum Result diff --git a/src/VisualStudio/Core/Def/RoslynActivityLogger.cs b/src/VisualStudio/Core/Def/RoslynActivityLogger.cs index fad90ccd7a549..d975bd7321df0 100644 --- a/src/VisualStudio/Core/Def/RoslynActivityLogger.cs +++ b/src/VisualStudio/Core/Def/RoslynActivityLogger.cs @@ -1,7 +1,8 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Immutable; using System.Diagnostics; using System.Threading; using Microsoft.CodeAnalysis; @@ -20,54 +21,61 @@ namespace Microsoft.VisualStudio.LanguageServices; /// internal static class RoslynActivityLogger { - private static readonly object s_gate = new(); + /// + /// A single sink, composed once at startup, whose contents vary rather than its + /// registration. Adding and removing a mutates this set; when the set is + /// empty the sink reports itself disabled and costs one array-length check per event. + /// + public static readonly TraceSourceSink Sink = new(); public static void SetLogger(TraceSource traceSource) { Contract.ThrowIfNull(traceSource); - - lock (s_gate) - { - // internally, it just uses our existing ILogger - Logger.SetLogger(AggregateLogger.AddOrReplace(new TraceSourceLogger(traceSource), Logger.GetLogger(), l => (l as TraceSourceLogger)?.TraceSource == traceSource)); - } + Sink.Add(traceSource); } public static void RemoveLogger(TraceSource traceSource) { Contract.ThrowIfNull(traceSource); - - lock (s_gate) - { - // internally, it just uses our existing ILogger - Logger.SetLogger(AggregateLogger.Remove(Logger.GetLogger(), l => (l as TraceSourceLogger)?.TraceSource == traceSource)); - } + Sink.Remove(traceSource); } - private sealed class TraceSourceLogger : ILogger + internal sealed class TraceSourceSink : IEventSink { private const int LogEventId = 0; private const int StartEventId = 1; private const int EndEventId = 2; - public readonly TraceSource TraceSource; + private ImmutableArray _traceSources = []; + + public void Add(TraceSource traceSource) + => ImmutableInterlocked.Update(ref _traceSources, static (sources, source) => sources.Contains(source) ? sources : sources.Add(source), traceSource); - public TraceSourceLogger(TraceSource traceSource) - => TraceSource = traceSource; + public void Remove(TraceSource traceSource) + => ImmutableInterlocked.Update(ref _traceSources, static (sources, source) => sources.Remove(source), traceSource); public bool IsEnabled(FunctionId functionId) { - // we log every roslyn activity - return true; + // we log every roslyn activity, but only while someone is listening + return !_traceSources.IsEmpty; } public void Log(FunctionId functionId, LogMessage logMessage) - => TraceSource.TraceData(TraceEventType.Verbose, LogEventId, functionId.Convert(), logMessage.GetMessage()); + { + foreach (var traceSource in _traceSources) + traceSource.TraceData(TraceEventType.Verbose, LogEventId, functionId.Convert(), logMessage.GetMessage()); + } public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken) - => TraceSource.TraceData(TraceEventType.Verbose, StartEventId, functionId.Convert(), uniquePairId); + { + foreach (var traceSource in _traceSources) + traceSource.TraceData(TraceEventType.Verbose, StartEventId, functionId.Convert(), uniquePairId); + } public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken) - => TraceSource.TraceData(TraceEventType.Verbose, EndEventId, functionId.Convert(), uniquePairId, cancellationToken.IsCancellationRequested, delta, logMessage.GetMessage()); + { + foreach (var traceSource in _traceSources) + traceSource.TraceData(TraceEventType.Verbose, EndEventId, functionId.Convert(), uniquePairId, cancellationToken.IsCancellationRequested, delta, logMessage.GetMessage()); + } } } diff --git a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs index 7921adfd5ddae..ed6679b378068 100644 --- a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.ErrorReporting; using Microsoft.CodeAnalysis.Internal.Log; @@ -17,17 +18,19 @@ internal abstract class AbstractWorkspaceTelemetryService : IWorkspaceTelemetryS { public TelemetrySession? CurrentSession { get; private set; } - protected abstract ILogger CreateLogger(TelemetrySession telemetrySession, bool logDelta); + protected abstract IEventSink CreateLogger(TelemetrySession telemetrySession, bool logDelta); public void InitializeTelemetrySession(TelemetrySession telemetrySession, bool logDelta) { Contract.ThrowIfFalse(CurrentSession is null); - Logger.SetLogger(CreateLogger(telemetrySession, logDelta)); + RoslynTelemetry.SetEventSink(CreateLogger(telemetrySession, logDelta)); + VSMetricSink.Create(telemetrySession); FaultReporter.RegisterTelemetrySesssion(telemetrySession); CurrentSession = telemetrySession; + StartPeriodicFlush(); TelemetrySessionInitialized(); } @@ -55,6 +58,25 @@ public void Dispose() { // Ensure any aggregate telemetry is flushed when the catalog is destroyed. // It is fine for this to be called multiple times - if telemetry has already been flushed this will no-op. - TelemetryLogging.Flush(); + RoslynTelemetry.Flush(); + } + + /// + /// Posts whatever has accumulated every 30 minutes. Shutdown paths flush explicitly as well, because + /// a host can exit too abruptly for a timer-based flush to run. + /// + private static void StartPeriodicFlush() + => _ = PostCollectedTelemetryAsync(); + + private static async Task PostCollectedTelemetryAsync() + { + await Task.Delay(TimeSpan.FromMinutes(30)).ConfigureAwait(false); + + RoslynTelemetry.Flush(); + + // Create a fire and forget task to handle the next collection. This doesn't use + // IAsynchronousOperationListener to track this work as no-one needs to ensure this is sent, and + // creating a new item of work upon previous completion doesn't fit well in that model. + _ = PostCollectedTelemetryAsync().ReportNonFatalErrorAsync(); } } diff --git a/src/VisualStudio/Core/Def/Telemetry/CodeMarkerLogger.cs b/src/VisualStudio/Core/Def/Telemetry/CodeMarkerLogger.cs index 00e906eeb3d40..ea838b7e2209b 100644 --- a/src/VisualStudio/Core/Def/Telemetry/CodeMarkerLogger.cs +++ b/src/VisualStudio/Core/Def/Telemetry/CodeMarkerLogger.cs @@ -11,7 +11,7 @@ namespace Microsoft.VisualStudio.LanguageServices.Telemetry; -internal sealed class CodeMarkerLogger : ILogger +internal sealed class CodeMarkerLogger : IEventSink { public static readonly CodeMarkerLogger Instance = new(); diff --git a/src/VisualStudio/Core/Def/Telemetry/FileLogger.cs b/src/VisualStudio/Core/Def/Telemetry/FileLogger.cs index 8be4ed4130e4d..0282cbafd7e9c 100644 --- a/src/VisualStudio/Core/Def/Telemetry/FileLogger.cs +++ b/src/VisualStudio/Core/Def/Telemetry/FileLogger.cs @@ -24,7 +24,7 @@ namespace Microsoft.VisualStudio.LanguageServices.Telemetry; /// /// A logger that publishes events to a log file. /// -internal sealed class FileLogger : ILogger +internal sealed class FileLogger : IEventSink { private readonly string _logFilePath; private bool _enabled; diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/AbstractAggregatingLog.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/AbstractAggregatingLog.cs deleted file mode 100644 index 9dde0732ae3d6..0000000000000 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/AbstractAggregatingLog.cs +++ /dev/null @@ -1,129 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Generic; -using System.Collections.Immutable; -using Microsoft.CodeAnalysis.Internal.Log; -using Microsoft.VisualStudio.Telemetry; -using Microsoft.VisualStudio.Telemetry.Metrics; -using Microsoft.VisualStudio.Telemetry.Metrics.Events; -using Roslyn.Utilities; - -namespace Microsoft.CodeAnalysis.Telemetry; - -/// -/// Provides a wrapper around various VSTelemetry aggregating APIs to support aggregated telemetry. Each instance -/// of this class corresponds to a specific FunctionId operation and can support aggregated values for each -/// metric name logged. -/// -internal abstract class AbstractAggregatingLog : ITelemetryLog where TAggregator : IInstrument -{ - // Indicates version information which vs telemetry will use for our aggregated telemetry. This can be used - // by Kusto queries to filter against telemetry versions which have the specified version and thus desired shape. - private const string MeterVersion = "0.40"; - - private readonly IMeter _meter; - private readonly TelemetrySession _session; - private readonly string _eventName; - private readonly FunctionId _functionId; - private readonly object _flushLock; - - private ImmutableDictionary _aggregations = ImmutableDictionary.Empty; - - /// - /// Creates a new aggregating telemetry log - /// - /// Telemetry session used to post events - /// Used to derive meter name - public AbstractAggregatingLog(TelemetrySession session, FunctionId functionId) - { - var meterName = TelemetryLogger.GetPropertyName(functionId, "meter"); - var meterProvider = new VSTelemetryMeterProvider(); - - _session = session; - _meter = meterProvider.CreateMeter(meterName, version: MeterVersion); - _eventName = TelemetryLogger.GetEventName(functionId); - _functionId = functionId; - _flushLock = new(); - } - - /// - /// Adds aggregated information for the metric and value passed in via . The Name/Value properties - /// are used as the metric name and value to record. - /// - /// - public void Log(KeyValueLogMessage logMessage) - { - if (!IsEnabled) - return; - - // Name is the key for this message in our aggregation dictionary. It is also used as the metric name - // if the MetricName property isn't specified. - if (!logMessage.Properties.TryGetValue(TelemetryLogging.KeyName, out var nameValue) || nameValue is not string name) - throw ExceptionUtilities.Unreachable(); - - if (!logMessage.Properties.TryGetValue(TelemetryLogging.KeyValue, out var valueValue) || valueValue is not TValue value) - throw ExceptionUtilities.Unreachable(); - - (var aggregator, _, var aggregatorLock) = ImmutableInterlocked.GetOrAdd(ref _aggregations, name, name => - { - var telemetryEvent = new TelemetryEvent(_eventName); - - // For aggregated telemetry, the first Log request that comes in for a particular name determines the additional - // properties added for the telemetry event. - if (!logMessage.Properties.TryGetValue(TelemetryLogging.KeyMetricName, out var metricNameValue) || metricNameValue is not string metricName) - metricName = name; - - foreach (var (curName, curValue) in logMessage.Properties) - { - if (curName is not TelemetryLogging.KeyName and not TelemetryLogging.KeyValue and not TelemetryLogging.KeyMetricName) - { - var propertyName = TelemetryLogger.GetPropertyName(_functionId, curName); - telemetryEvent.Properties.Add(propertyName, curValue); - } - } - - var aggregator = CreateAggregator(_meter, metricName); - var aggregatorLock = new object(); - - return (aggregator, telemetryEvent, aggregatorLock); - }); - - lock (aggregatorLock) - { - UpdateAggregator(aggregator, value); - } - } - - protected abstract TAggregator CreateAggregator(IMeter meter, string metricName); - - protected abstract void UpdateAggregator(TAggregator aggregator, TValue value); - - protected abstract TelemetryMetricEvent CreateTelemetryEvent(TelemetryEvent telemetryEvent, TAggregator aggregator); - - protected bool IsEnabled => _session.IsOptedIn; - - public void Flush() - { - // This lock ensures that multiple calls to Flush cannot occur simultaneously. - // Without this lock, we would could potentially call PostMetricEvent multiple - // times for the same aggregation. - lock (_flushLock) - { - foreach (var (aggregator, telemetryEvent, aggregatorLock) in _aggregations.Values) - { - // This fine-grained lock ensures that the aggregation isn't modified (via a Record call) - // during the creation of the TelemetryMetricEvent or the PostMetricEvent - // call that operates on it. - lock (aggregatorLock) - { - var aggregatorEvent = CreateTelemetryEvent(telemetryEvent, aggregator); - _session.PostMetricEvent(aggregatorEvent); - } - } - - _aggregations = ImmutableDictionary.Empty; - } - } -} diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/AggregatingCounterLog.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/AggregatingCounterLog.cs deleted file mode 100644 index bb9ab02a21a3e..0000000000000 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/AggregatingCounterLog.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Microsoft.CodeAnalysis.Internal.Log; -using Microsoft.VisualStudio.Telemetry; -using Microsoft.VisualStudio.Telemetry.Metrics; -using Microsoft.VisualStudio.Telemetry.Metrics.Events; - -namespace Microsoft.CodeAnalysis.Telemetry; - -/// -/// Provides a wrapper around the VSTelemetry counter APIs to support aggregated counter telemetry. Each instance -/// of this class corresponds to a specific FunctionId operation and can support counting aggregated values for each -/// metric name logged. -/// -internal sealed class AggregatingCounterLog : AbstractAggregatingLog, long> -{ - public AggregatingCounterLog(TelemetrySession session, FunctionId functionId) : base(session, functionId) - { - } - - protected override ICounter CreateAggregator(IMeter meter, string metricName) - { - return meter.CreateCounter(metricName); - } - - protected override void UpdateAggregator(ICounter counter, long value) - { - counter.Add(value); - } - - protected override TelemetryMetricEvent CreateTelemetryEvent(TelemetryEvent telemetryEvent, ICounter counter) - { - return new TelemetryCounterEvent(telemetryEvent, counter); - } -} diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/AggregatingHistogramLog.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/AggregatingHistogramLog.cs deleted file mode 100644 index 72bcb15a2c9b9..0000000000000 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/AggregatingHistogramLog.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using Microsoft.CodeAnalysis.Internal.Log; -using Microsoft.VisualStudio.Telemetry; -using Microsoft.VisualStudio.Telemetry.Metrics; -using Microsoft.VisualStudio.Telemetry.Metrics.Events; - -namespace Microsoft.CodeAnalysis.Telemetry; - -/// -/// Provides a wrapper around the VSTelemetry histogram APIs to support aggregated telemetry. Each instance -/// of this class corresponds to a specific FunctionId operation and can support aggregated values for each -/// metric name logged. -/// -internal sealed class AggregatingHistogramLog : AbstractAggregatingLog, long>, ITelemetryBlockLog -{ - private readonly HistogramConfiguration? _histogramConfiguration; - - /// - /// Creates a new aggregating telemetry log - /// - /// Telemetry session used to post events - /// Used to derive meter name - /// Optional values indicating bucket boundaries in milliseconds. If not specified, - /// all histograms created will use the default histogram configuration - public AggregatingHistogramLog(TelemetrySession session, FunctionId functionId, double[]? bucketBoundaries) : base(session, functionId) - { - if (bucketBoundaries != null) - { - _histogramConfiguration = new HistogramConfiguration(bucketBoundaries); - } - } - - public IDisposable? LogBlockTime(KeyValueLogMessage logMessage, int minThresholdMs) - { - if (!IsEnabled) - return null; - - if (!logMessage.Properties.TryGetValue(TelemetryLogging.KeyName, out var nameValue) || nameValue is not string) - throw ExceptionUtilities.Unreachable(); - - return new TimedTelemetryLogBlock(logMessage, minThresholdMs, telemetryLog: this); - } - - protected override IHistogram CreateAggregator(IMeter meter, string metricName) - { - return meter.CreateHistogram(metricName, _histogramConfiguration); - } - - protected override void UpdateAggregator(IHistogram histogram, long value) - { - histogram.Record(value); - } - - protected override TelemetryMetricEvent CreateTelemetryEvent(TelemetryEvent telemetryEvent, IHistogram histogram) - { - return new TelemetryHistogramEvent(telemetryEvent, histogram); - } -} diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogProvider.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogProvider.cs deleted file mode 100644 index a21a3926fcf9f..0000000000000 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogProvider.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Immutable; -using Microsoft.CodeAnalysis.Internal.Log; -using Microsoft.CodeAnalysis.Telemetry; -using Microsoft.VisualStudio.Telemetry; - -namespace Microsoft.VisualStudio.LanguageServices.Telemetry; - -/// -/// Provides access to an appropriate for logging telemetry. -/// -internal sealed class TelemetryLogProvider : ITelemetryLogProvider -{ - private readonly TelemetrySession _session; - private readonly ILogger _telemetryLogger; - - /// - /// Manages instances of to provide in - /// - private ImmutableDictionary _logs = ImmutableDictionary.Empty; - - /// - /// Manages instances of to provide in - /// - private ImmutableDictionary _histogramLogs = ImmutableDictionary.Empty; - - /// - /// Manages instances of to provide in - /// - private ImmutableDictionary _counterLogs = ImmutableDictionary.Empty; - - private TelemetryLogProvider(TelemetrySession session, ILogger telemetryLogger) - { - _session = session; - _telemetryLogger = telemetryLogger; - } - - public static TelemetryLogProvider Create(TelemetrySession session, ILogger telemetryLogger) - { - var logProvider = new TelemetryLogProvider(session, telemetryLogger); - - TelemetryLogging.SetLogProvider(logProvider); - - return logProvider; - } - - /// - /// Returns an for logging telemetry. - /// - public ITelemetryBlockLog? GetLog(FunctionId functionId) - { - if (!_session.IsOptedIn) - return null; - - return ImmutableInterlocked.GetOrAdd(ref _logs, functionId, functionId => new VisualStudioTelemetryLog(_telemetryLogger, functionId)); - } - - /// - /// Returns an aggregating for logging telemetry. - /// - public ITelemetryBlockLog? GetHistogramLog(FunctionId functionId, double[]? bucketBoundaries) - { - if (!_session.IsOptedIn) - return null; - - return ImmutableInterlocked.GetOrAdd( - ref _histogramLogs, - functionId, - static (functionId, arg) => new AggregatingHistogramLog(arg._session, functionId, arg.bucketBoundaries), - factoryArgument: (_session, bucketBoundaries)); - } - - public ITelemetryLog? GetCounterLog(FunctionId functionId) - { - if (!_session.IsOptedIn) - return null; - - return ImmutableInterlocked.GetOrAdd( - ref _counterLogs, - functionId, - static (functionId, session) => new AggregatingCounterLog(session, functionId), - factoryArgument: _session); - } - - /// - /// Flushes all telemetry logs - /// - public void Flush() - { - if (!_session.IsOptedIn) - return; - - foreach (var log in _histogramLogs.Values) - { - log.Flush(); - } - - foreach (var log in _counterLogs.Values) - { - log.Flush(); - } - } -} diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs index 3a39fc25fd658..8db8124054f52 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs @@ -14,7 +14,7 @@ namespace Microsoft.CodeAnalysis.Telemetry; -internal abstract class TelemetryLogger : ILogger +internal abstract class TelemetryLogger : IEventSink { private sealed class Implementation : TelemetryLogger { @@ -27,15 +27,7 @@ private Implementation(TelemetrySession session, bool logDelta) } public static new Implementation Create(TelemetrySession session, bool logDelta) - { - var logger = new Implementation(session, logDelta); - - // Two stage initialization as TelemetryLogProvider.Create needs access to - // the ILogger that this class implements. - TelemetryLogProvider.Create(session, logger); - - return logger; - } + => new(session, logDelta); protected override bool LogDelta { get; } @@ -74,24 +66,13 @@ protected override void End(object scope, TelemetryResult result) private readonly ConcurrentDictionary _pendingScopes = new(concurrencyLevel: 2, capacity: 10); - private const string EventPrefix = "vs/ide/vbcs/"; - private const string PropertyPrefix = "vs.ide.vbcs."; - - // these don't have concurrency limit on purpose to reduce chance of lock contention. - // if that becomes a problem - by showing up in our perf investigation, then we will consider adding concurrency limit. - private static readonly ConcurrentDictionary s_eventMap = []; - private static readonly ConcurrentDictionary<(FunctionId id, string name), string> s_propertyMap = []; - protected abstract bool LogDelta { get; } internal static string GetEventName(FunctionId id) - => s_eventMap.GetOrAdd(id, id => EventPrefix + GetTelemetryName(id, separator: '/')); + => TelemetryNaming.GetEventName(id); internal static string GetPropertyName(FunctionId id, string name) - => s_propertyMap.GetOrAdd((id, name), key => PropertyPrefix + GetTelemetryName(id, separator: '.') + "." + key.name.ToLowerInvariant()); - - private static string GetTelemetryName(FunctionId id, char separator) - => Enum.GetName(typeof(FunctionId), id)!.Replace('_', separator).ToLowerInvariant(); + => TelemetryNaming.GetPropertyName(id, name); public static TelemetryLogger Create(TelemetrySession session, bool logDelta) => Implementation.Create(session, logDelta); diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/TimedTelemetryLogBlock.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/TimedTelemetryLogBlock.cs deleted file mode 100644 index 945dd14bfc98b..0000000000000 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/TimedTelemetryLogBlock.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Diagnostics; -using Microsoft.CodeAnalysis.Collections; -using Microsoft.CodeAnalysis.Internal.Log; -using Roslyn.Utilities; - -namespace Microsoft.CodeAnalysis.Telemetry; - -/// -/// Provides a mechanism to log telemetry information containing the execution time between -/// creation and disposal of this object. -/// -internal sealed class TimedTelemetryLogBlock : IDisposable -{ - private readonly KeyValueLogMessage _logMessage; - private readonly int _minThresholdMs; -#pragma warning disable IDE0052 // Remove unread private members - Not used in debug builds - private readonly ITelemetryLog _telemetryLog; -#pragma warning restore IDE0052 // Remove unread private members - private readonly SharedStopwatch _stopwatch; - - public TimedTelemetryLogBlock(KeyValueLogMessage logMessage, int minThresholdMs, ITelemetryLog telemetryLog) - { - _logMessage = logMessage; - _minThresholdMs = minThresholdMs; - _telemetryLog = telemetryLog; - _stopwatch = SharedStopwatch.StartNew(); - } - - public void Dispose() - { - var elapsed = (long)_stopwatch.Elapsed.TotalMilliseconds; - if (elapsed >= _minThresholdMs) - { - var logMessage = KeyValueLogMessage.Create(m => - { - m[TelemetryLogging.KeyValue] = elapsed; - - m.AddRange(_logMessage.Properties); - }); - -#if !DEBUG - // Don't skew telemetry results by logging in debug bits or under debugger. - if (!Debugger.IsAttached) - _telemetryLog.Log(logMessage); -#endif - logMessage.Free(); - } - - _logMessage.Free(); - } -} diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs new file mode 100644 index 0000000000000..2b1bc88e558dd --- /dev/null +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -0,0 +1,245 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using Microsoft.CodeAnalysis.Internal.Log; +using Microsoft.CodeAnalysis.PooledObjects; +using Microsoft.VisualStudio.Telemetry; +using Microsoft.VisualStudio.Telemetry.Metrics; +using Microsoft.VisualStudio.Telemetry.Metrics.Events; +using Roslyn.Utilities; + +namespace Microsoft.CodeAnalysis.Telemetry; + +/// +/// The single aggregating metric implementation, backed by VS Telemetry's counter/histogram APIs. +/// +/// Replaces four previously separate wrappers around the same VS Telemetry surface +/// (AbstractAggregatingLog, AggregatingCounterLog, AggregatingHistogramLog, and +/// TelemetryLogProvider) as well as Razor's independent copy. +/// +/// +/// Aggregation is keyed by in addition to the instrument identity, so +/// a process hosting more than one logical session accumulates - and posts - each session's data +/// separately. is deliberately global: it walks every bucket, posts each to the +/// session that produced it, and clears. Clearing on flush is also what keeps a long-lived process from +/// accruing buckets for sessions that have ended. +/// +/// +internal sealed class VSMetricSink : IMetricSink +{ + /// + /// Indicates version information which vs telemetry will use for our aggregated telemetry. This can be used + /// by Kusto queries to filter against telemetry versions which have the specified version and thus desired shape. + /// + private const string MeterVersion = "0.40"; + + /// + /// The per-session capability this sink actually needs. Exists so that tests can assert exactly how + /// many metric events a flush posts without standing up a real, opted-in + /// (which would try to send). + /// + internal interface IMetricPoster + { + bool IsOptedIn { get; } + void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent); + } + + private sealed class SessionPoster(TelemetrySession session) : IMetricPoster + { + public bool IsOptedIn => session.IsOptedIn; + public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent) => session.PostMetricEvent(metricEvent); + } + + private readonly record struct AggregationKey(TelemetrySessionKey Session, string EventName, string MetricName, string DimensionKey); + + private sealed class Aggregation(IInstrument instrument, TelemetryEvent telemetryEvent, IMetricPoster poster) + { + public IInstrument Instrument { get; } = instrument; + public TelemetryEvent TelemetryEvent { get; } = telemetryEvent; + public IMetricPoster Poster { get; } = poster; + + /// + /// Guards this single aggregation. Paired with exactly as the + /// previous implementation did - see https://github.com/dotnet/roslyn/pull/71606, which added this + /// two-level locking because concurrent PostMetricEvent calls for one instrument were crashing. + /// + public object Lock { get; } = new(); + } + + /// + /// Ensures two flushes cannot run at once, which would post the same aggregation twice. + /// + private readonly object _flushLock = new(); + + private readonly VSTelemetryMeterProvider _meterProvider = new(); + private readonly IMetricPoster _defaultPoster; + + private ImmutableDictionary _aggregations = ImmutableDictionary.Empty; + private ImmutableDictionary _meters = ImmutableDictionary.Empty; + private ImmutableDictionary _posters = ImmutableDictionary.Empty; + + internal VSMetricSink(IMetricPoster defaultPoster) + { + _defaultPoster = defaultPoster; + _posters = _posters.Add(TelemetrySessionKey.Default, defaultPoster); + } + + /// + /// Creates the sink and registers it as the process-wide metric destination. + /// + public static VSMetricSink Create(TelemetrySession session) + { + var sink = new VSMetricSink(new SessionPoster(session)); + RoslynTelemetry.SetMetricSink(sink); + return sink; + } + + /// + /// Associates a session with a key, for hosts that run more than one logical session per process. + /// + public void RegisterSession(TelemetrySessionKey key, TelemetrySession session) + { + var poster = new SessionPoster(session); + ImmutableInterlocked.AddOrUpdate(ref _posters, key, poster, (_, _) => poster); + } + + public void Count(string eventName, string metricName, long delta, ReadOnlySpan> tags) + { + if (GetOrCreateAggregation(eventName, metricName, tags, isCounter: true) is not { } aggregation) + return; + + lock (aggregation.Lock) + { + ((ICounter)aggregation.Instrument).Add(delta); + } + } + + public void Record(string eventName, string metricName, long value, ReadOnlySpan> tags) + { + if (GetOrCreateAggregation(eventName, metricName, tags, isCounter: false) is not { } aggregation) + return; + + lock (aggregation.Lock) + { + ((IHistogram)aggregation.Instrument).Record(value); + } + } + + public void Flush() + { + // This lock ensures that multiple calls to Flush cannot occur simultaneously. Without it we could + // call PostMetricEvent multiple times for the same aggregation. + lock (_flushLock) + { + var aggregations = Interlocked.Exchange(ref _aggregations, ImmutableDictionary.Empty); + + foreach (var pair in aggregations) + { + var aggregation = pair.Value; + if (!aggregation.Poster.IsOptedIn) + continue; + + // This fine-grained lock ensures the aggregation isn't modified (via an Add/Record call) + // during the creation of the TelemetryMetricEvent or the PostMetricEvent call on it. + lock (aggregation.Lock) + { + TelemetryMetricEvent metricEvent = aggregation.Instrument switch + { + ICounter counter => new TelemetryCounterEvent(aggregation.TelemetryEvent, counter), + IHistogram histogram => new TelemetryHistogramEvent(aggregation.TelemetryEvent, histogram), + _ => throw ExceptionUtilities.UnexpectedValue(aggregation.Instrument), + }; + + aggregation.Poster.Post(aggregation.TelemetryEvent, metricEvent); + } + } + } + } + + private Aggregation? GetOrCreateAggregation(string eventName, string metricName, ReadOnlySpan> tags, bool isCounter) + { + var sessionKey = RoslynTelemetry.CurrentSessionKey; + if (!_posters.TryGetValue(sessionKey, out var poster)) + poster = _defaultPoster; + + // Consent is checked here rather than at the call site so that no telemetry object graph is built + // for an opted-out session -- the source of a large amount of throwaway allocation historically. + if (!poster.IsOptedIn) + return null; + + var key = new AggregationKey(sessionKey, eventName, metricName, BuildDimensionKey(tags)); + + if (_aggregations.TryGetValue(key, out var existing)) + return existing; + + return ImmutableInterlocked.GetOrAdd( + ref _aggregations, + key, + static (key, arg) => arg.self.CreateAggregation(key, arg.tags, arg.isCounter, arg.poster), + (self: this, tags: tags.ToArray(), isCounter, poster)); + } + + private Aggregation CreateAggregation(AggregationKey key, KeyValuePair[] tags, bool isCounter, IMetricPoster poster) + { + var telemetryEvent = new TelemetryEvent(key.EventName); + + foreach (var (name, value) in tags) + telemetryEvent.Properties.Add(GetPropertyName(key.EventName, name), value); + + var meter = GetOrCreateMeter(key.EventName); + IInstrument instrument = isCounter + ? meter.CreateCounter(key.MetricName) + : meter.CreateHistogram(key.MetricName); + + return new Aggregation(instrument, telemetryEvent, poster); + } + + private IMeter GetOrCreateMeter(string eventName) + => ImmutableInterlocked.GetOrAdd( + ref _meters, + eventName, + static (eventName, provider) => provider.CreateMeter(GetMeterName(eventName), version: MeterVersion), + _meterProvider); + + /// + /// Reproduces the meter name the previous per-FunctionId implementation produced + /// (vs.ide.vbcs.some.operation.meter) from the already-derived event name + /// (vs/ide/vbcs/some/operation), so emitted telemetry keeps its existing shape. + /// + private static string GetMeterName(string eventName) + => eventName.Replace('/', '.') + ".meter"; + + /// + /// Reproduces the previous property naming (vs.ide.vbcs.some.operation.tagname). + /// + private static string GetPropertyName(string eventName, string tagName) + => eventName.Replace('/', '.') + "." + tagName.ToLowerInvariant(); + + /// + /// Builds the bucket discriminator from the tag values, in declaration order. This reproduces the + /// compound name the previous call sites concatenated by hand (for example + /// "server.method.language") so that measurements aggregate exactly as they did before. + /// + private static string BuildDimensionKey(ReadOnlySpan> tags) + { + if (tags.Length == 0) + return ""; + + using var _ = PooledStringBuilder.GetInstance(out var builder); + + for (var i = 0; i < tags.Length; i++) + { + if (i > 0) + builder.Append('.'); + + builder.Append(tags[i].Value?.ToString()); + } + + return builder.ToString(); + } +} diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VisualStudioTelemetryLog.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VisualStudioTelemetryLog.cs deleted file mode 100644 index 6b8c429398539..0000000000000 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VisualStudioTelemetryLog.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using Microsoft.CodeAnalysis.Internal.Log; -using Microsoft.CodeAnalysis.Telemetry; - -namespace Microsoft.VisualStudio.LanguageServices.Telemetry; - -internal sealed class VisualStudioTelemetryLog : ITelemetryBlockLog -{ - private readonly ILogger _telemetryLogger; - private readonly FunctionId _functionId; - - public VisualStudioTelemetryLog(ILogger telemetryLogger, FunctionId functionId) - { - _telemetryLogger = telemetryLogger; - _functionId = functionId; - } - - public void Log(KeyValueLogMessage logMessage) - { - _telemetryLogger.Log(_functionId, logMessage); - } - - public IDisposable? LogBlockTime(KeyValueLogMessage logMessage, int minThresholdMs) - { - return new TimedTelemetryLogBlock(logMessage, minThresholdMs, telemetryLog: this); - } -} diff --git a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs index d0b8985ba084a..2de6a0c0b77c2 100644 --- a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs @@ -31,13 +31,38 @@ internal sealed class VisualStudioWorkspaceTelemetryService( private readonly Lazy _workspace = workspace; private readonly IGlobalOptionService _globalOptions = globalOptions; - protected override ILogger CreateLogger(TelemetrySession telemetrySession, bool logDelta) - => AggregateLogger.Create( + /// + /// Opt-in diagnostic sinks. Composed once, at startup, and thereafter enabled or disabled through + /// their own predicates by the Performance Loggers options page - never added to or removed from the + /// sink list, which is what guarantees each is registered exactly once. + /// + private EtwLogger? _etwLogger; + private TraceLogger? _traceLogger; + + protected override IEventSink CreateLogger(TelemetrySession telemetrySession, bool logDelta) + { + _etwLogger = new EtwLogger(FunctionIdOptions.CreateFunctionIsEnabledPredicate(_globalOptions)); + _traceLogger = new TraceLogger(EtwLogger.DisabledPredicate); + + return AggregateEventSink.Create( CodeMarkerLogger.Instance, - new EtwLogger(FunctionIdOptions.CreateFunctionIsEnabledPredicate(_globalOptions)), + _etwLogger, + _traceLogger, + RoslynActivityLogger.Sink, TelemetryLogger.Create(telemetrySession, logDelta), new FileLogger(_globalOptions, _threadingContext), - Logger.GetLogger()); + RoslynTelemetry.GetEventSink()); + } + + /// + /// Refreshes the enablement of the composed opt-in sinks. Called by the Performance Loggers options + /// page; deliberately updates the existing instances rather than constructing new ones. + /// + internal void UpdateDiagnosticSinkEnablement(bool etwEnabled, bool traceEnabled, Func isEnabled) + { + _etwLogger?.UpdatePredicate(etwEnabled ? isEnabled : EtwLogger.DisabledPredicate); + _traceLogger?.UpdatePredicate(traceEnabled ? isEnabled : EtwLogger.DisabledPredicate); + } protected override void TelemetrySessionInitialized() { diff --git a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs index 65dd675cd3f83..74dc0aee2a1d6 100644 --- a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs +++ b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs @@ -1949,13 +1949,13 @@ void M() Assert.Equal("CSharp.ConflictMarkerResolution.CSharpResolveConflictMarkerCodeFixProvider", result.CodeFixAnalysis.DiagnosticIdToProviderName["CS8300"].Single()); var logger = new TestTelemetryLogger(); - Logger.SetLogger(logger); + RoslynTelemetry.SetEventSink(logger); TestTelemetryLogger.TestScope scope; using (CopilotChangeAnalysisUtilities.LogCopilotChangeAnalysis("TestCode", accepted: true, "TestProposalId", result, CancellationToken.None)) { scope = logger.OpenedScopes.Single(); } - Logger.SetLogger(null); + RoslynTelemetry.SetEventSink(null); var endEvent = scope.EndEvent; Assert.Equal("vs/ide/vbcs/copilot/analyzechange", endEvent.Name); diff --git a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs index 7d84c1b0b35cf..e39ac5988ef92 100644 --- a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs +++ b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs @@ -100,7 +100,7 @@ public static async Task CodeActionAsync( if (!RoslynString.IsNullOrEmpty(applyFix)) { var codeActionLogger = new CodeActionLogger(); - using var loggerRestorer = WithLogger(AggregateLogger.AddOrReplace(codeActionLogger, Logger.GetLogger(), logger => logger is CodeActionLogger)); + using var loggerRestorer = WithLogger(AggregateEventSink.Create(RoslynTelemetry.GetEventSink(), codeActionLogger)); var result = await textViewWindowVerifier.TestServices.Editor.ApplyLightBulbActionAsync(applyFix, fixAllScope, blockUntilComplete, cancellationToken); @@ -154,12 +154,12 @@ await textViewWindowVerifier.TestServices.Workspace.WaitForAllAsyncOperationsAsy Assert.NotEqual("text", tokenType); } - private static LoggerRestorer WithLogger(ILogger logger) + private static LoggerRestorer WithLogger(IEventSink logger) { - return new LoggerRestorer(Logger.SetLogger(logger)); + return new LoggerRestorer(RoslynTelemetry.SetEventSink(logger)); } - private sealed class CodeActionLogger : ILogger + private sealed class CodeActionLogger : IEventSink { public List Messages { get; } = []; @@ -190,16 +190,16 @@ public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniq private readonly struct LoggerRestorer : IDisposable { - private readonly ILogger? _logger; + private readonly IEventSink? _logger; - public LoggerRestorer(ILogger? logger) + public LoggerRestorer(IEventSink? logger) { _logger = logger; } public void Dispose() { - Logger.SetLogger(_logger); + RoslynTelemetry.SetEventSink(_logger); } } } diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs index 58eb016c5a390..16d1b751ff419 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs @@ -16,19 +16,37 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// Implementation of that output to output window +/// Implementation of that output to output window /// -internal sealed class OutputWindowLogger : ILogger +internal sealed class OutputWindowLogger : IEventSink { - private readonly Func _isEnabledPredicate; + /// + /// Lives in the diagnostics tool window VSIX, which the telemetry composition root cannot reference, + /// so this attaches itself once and is thereafter controlled purely by its predicate. + /// + public static readonly OutputWindowLogger Instance = new(EtwLogger.DisabledPredicate); - public OutputWindowLogger(Func isEnabledPredicate) + private static int s_registered; + + private Func _isEnabledPredicate; + + private OutputWindowLogger(Func isEnabledPredicate) { _isEnabledPredicate = isEnabledPredicate; } + public static void EnsureRegistered() + { + if (Interlocked.CompareExchange(ref s_registered, 1, 0) == 0) + RoslynTelemetry.AddEventSink(Instance); + } + + /// + public void UpdatePredicate(Func isEnabledPredicate) + => Volatile.Write(ref _isEnabledPredicate, isEnabledPredicate); + public bool IsEnabled(FunctionId functionId) - => _isEnabledPredicate(functionId); + => Volatile.Read(ref _isEnabledPredicate)(functionId); public void Log(FunctionId functionId, LogMessage logMessage) { diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs index 63e45dbf7ed5e..32af957f0177a 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs @@ -14,10 +14,12 @@ using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.Remote; +using Microsoft.CodeAnalysis.Telemetry; using Microsoft.VisualStudio.ComponentModelHost; using Microsoft.VisualStudio.LanguageServices; using Microsoft.VisualStudio.LanguageServices.Implementation; using Microsoft.VisualStudio.LanguageServices.Implementation.Options; +using Microsoft.VisualStudio.LanguageServices.Telemetry; using Roslyn.Utilities; namespace Roslyn.VisualStudio.DiagnosticsWindow.OptionsPages; @@ -54,19 +56,32 @@ protected override void OnApply(PageApplyEventArgs e) public static void SetLoggers(IGlobalOptionService globalOptions, IThreadingContext threadingContext, SolutionServices workspaceServices) { - var loggerTypeNames = GetLoggerTypes(globalOptions).ToImmutableArray(); - - // update loggers in VS var isEnabled = FunctionIdOptions.CreateFunctionIsEnabledPredicate(globalOptions); - SetRoslynLogger(loggerTypeNames, () => new EtwLogger(isEnabled)); - SetRoslynLogger(loggerTypeNames, () => new TraceLogger(isEnabled)); - SetRoslynLogger(loggerTypeNames, () => new OutputWindowLogger(isEnabled)); + var etwEnabled = globalOptions.GetOption(LoggerOptionsStorage.EtwLoggerKey); + var traceEnabled = globalOptions.GetOption(LoggerOptionsStorage.TraceLoggerKey); + var outputWindowEnabled = globalOptions.GetOption(LoggerOptionsStorage.OutputWindowLoggerKey); + + // ETW and Trace sinks are part of VS's default composition, so refresh those instances rather + // than constructing competing ones - two registered EtwLoggers would post every event twice. + var telemetryService = workspaceServices.GetService() as VisualStudioWorkspaceTelemetryService; + telemetryService?.UpdateDiagnosticSinkEnablement(etwEnabled, traceEnabled, isEnabled); + + // The output window sink lives in this (separately shipped) VSIX, so the composition root cannot + // reference it. Attach it once, then control it purely through its predicate. + OutputWindowLogger.EnsureRegistered(); + OutputWindowLogger.Instance.UpdatePredicate(outputWindowEnabled ? isEnabled : EtwLogger.DisabledPredicate); // update loggers in remote process var client = threadingContext.JoinableTaskFactory.Run(() => RemoteHostClient.TryGetClientAsync(workspaceServices, CancellationToken.None)); if (client != null) { + var loggerTypeNames = ImmutableArray.Empty; + if (etwEnabled) + loggerTypeNames = loggerTypeNames.Add(nameof(EtwLogger)); + if (traceEnabled) + loggerTypeNames = loggerTypeNames.Add(nameof(TraceLogger)); + var functionIds = Enum.GetValues().WhereAsArray(isEnabled); threadingContext.JoinableTaskFactory.Run(async () => _ = await client.TryInvokeAsync( @@ -74,34 +89,4 @@ public static void SetLoggers(IGlobalOptionService globalOptions, IThreadingCont CancellationToken.None).ConfigureAwait(false)); } } - - private static IEnumerable GetLoggerTypes(IGlobalOptionService globalOptions) - { - if (globalOptions.GetOption(LoggerOptionsStorage.EtwLoggerKey)) - { - yield return nameof(EtwLogger); - } - - if (globalOptions.GetOption(LoggerOptionsStorage.TraceLoggerKey)) - { - yield return nameof(TraceLogger); - } - - if (globalOptions.GetOption(LoggerOptionsStorage.OutputWindowLoggerKey)) - { - yield return nameof(OutputWindowLogger); - } - } - - private static void SetRoslynLogger(ImmutableArray loggerTypeNames, Func creator) where T : ILogger - { - if (loggerTypeNames.Contains(typeof(T).Name)) - { - Logger.SetLogger(AggregateLogger.AddOrReplace(creator(), Logger.GetLogger(), l => l is T)); - } - else - { - Logger.SetLogger(AggregateLogger.Remove(Logger.GetLogger(), l => l is T)); - } - } } diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfEventActivityLogger.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfEventActivityLogger.cs index e4987ef535784..3a70c2939c8be 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfEventActivityLogger.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfEventActivityLogger.cs @@ -11,7 +11,7 @@ namespace Roslyn.Hosting.Diagnostics.PerfMargin; // This version updates the DataModel whenever an operations starts or stops. There // isn't an efficient way to listen to ETW events within the same process unless // running as admin, so we need to add our logic to the logger instead. -internal sealed class PerfEventActivityLogger : ILogger +internal sealed class PerfEventActivityLogger : IEventSink { private readonly DataModel _model; diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs index 55bd9fa611b53..8f6b81168b901 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Windows; using System.Windows.Controls; using System.Windows.Threading; @@ -19,6 +20,7 @@ public sealed class PerfMarginPanel : UserControl { private static readonly DataModel s_model = new(); private static readonly PerfEventActivityLogger s_logger = new(s_model); + private static int s_registered; private readonly ListView _mainListView; private readonly Grid _mainGrid; @@ -31,7 +33,10 @@ public sealed class PerfMarginPanel : UserControl public PerfMarginPanel() { - Logger.SetLogger(AggregateLogger.AddOrReplace(s_logger, Logger.GetLogger(), l => l is PerfEventActivityLogger)); + // This panel lives in a separately shipped VSIX, so the composition root cannot reference this + // sink. Attach it once; it is never detached. + if (Interlocked.CompareExchange(ref s_registered, 1, 0) == 0) + RoslynTelemetry.AddEventSink(s_logger); // grid _mainGrid = new Grid(); diff --git a/src/Workspaces/Core/Portable/CodeActions/CodeAction.cs b/src/Workspaces/Core/Portable/CodeActions/CodeAction.cs index 343bd759a0530..5617a1e0f2b99 100644 --- a/src/Workspaces/Core/Portable/CodeActions/CodeAction.cs +++ b/src/Workspaces/Core/Portable/CodeActions/CodeAction.cs @@ -263,7 +263,7 @@ public Task> GetPreviewOperationsAsync(Cance internal async Task> GetPreviewOperationsAsync( Solution originalSolution, CancellationToken cancellationToken) { - using var _ = TelemetryLogging.LogBlockTimeAggregatedHistogram(FunctionId.SuggestedAction_Preview_Summary, $"Total"); + using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.SuggestedAction_Preview_Summary, $"Total"); var operations = await this.ComputePreviewOperationsAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Workspaces/Core/Portable/Log/AggregateLogger.cs b/src/Workspaces/Core/Portable/Log/AggregateLogger.cs deleted file mode 100644 index 687869153eec9..0000000000000 --- a/src/Workspaces/Core/Portable/Log/AggregateLogger.cs +++ /dev/null @@ -1,156 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -#nullable disable - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading; -using Roslyn.Utilities; - -namespace Microsoft.CodeAnalysis.Internal.Log; - -/// -/// a logger that aggregate multiple loggers -/// -internal sealed class AggregateLogger : ILogger -{ - private readonly ImmutableArray _loggers; - - public static AggregateLogger Create(params ILogger[] loggers) - { - var set = new HashSet(); - - // flatten loggers - foreach (var logger in loggers.WhereNotNull()) - { - if (logger is AggregateLogger aggregateLogger) - { - set.UnionWith(aggregateLogger._loggers); - continue; - } - - set.Add(logger); - } - - return new AggregateLogger([.. set]); - } - - public static ILogger AddOrReplace(ILogger newLogger, ILogger oldLogger, Func predicate) - { - if (newLogger == null) - { - return oldLogger; - } - - if (oldLogger == null) - { - return newLogger; - } - - if (oldLogger is not AggregateLogger aggregateLogger) - { - // replace old logger with new logger - if (predicate(oldLogger)) - { - // this might not aggregate logger - return newLogger; - } - - // merge two - return new AggregateLogger([newLogger, oldLogger]); - } - - var set = new HashSet(); - foreach (var logger in aggregateLogger._loggers) - { - // replace this logger with new logger - if (predicate(logger)) - { - set.Add(newLogger); - continue; - } - - // add old one back - set.Add(logger); - } - - // add new logger. if we already added one, this will be ignored. - set.Add(newLogger); - return new AggregateLogger([.. set]); - } - - public static ILogger Remove(ILogger logger, Func predicate) - { - if (logger is not AggregateLogger aggregateLogger) - { - // remove the logger - if (predicate(logger)) - { - return null; - } - - return logger; - } - - // filter out loggers - var set = aggregateLogger._loggers.Where(l => !predicate(l)).ToSet(); - if (set.Count == 1) - { - return set.Single(); - } - - return new AggregateLogger([.. set]); - } - - private AggregateLogger(ImmutableArray loggers) - => _loggers = loggers; - - public bool IsEnabled(FunctionId functionId) - => true; - - public void Log(FunctionId functionId, LogMessage logMessage) - { - for (var i = 0; i < _loggers.Length; i++) - { - var logger = _loggers[i]; - if (!logger.IsEnabled(functionId)) - { - continue; - } - - logger.Log(functionId, logMessage); - } - } - - public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken) - { - for (var i = 0; i < _loggers.Length; i++) - { - var logger = _loggers[i]; - if (!logger.IsEnabled(functionId)) - { - continue; - } - - logger.LogBlockStart(functionId, logMessage, uniquePairId, cancellationToken); - } - } - - public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken) - { - for (var i = 0; i < _loggers.Length; i++) - { - var logger = _loggers[i]; - if (!logger.IsEnabled(functionId)) - { - continue; - } - - logger.LogBlockEnd(functionId, logMessage, uniquePairId, delta, cancellationToken); - } - } -} diff --git a/src/Workspaces/Core/Portable/Log/EmptyLogger.cs b/src/Workspaces/Core/Portable/Log/EmptyLogger.cs deleted file mode 100644 index 7a118d9eae121..0000000000000 --- a/src/Workspaces/Core/Portable/Log/EmptyLogger.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Threading; - -namespace Microsoft.CodeAnalysis.Internal.Log; - -/// -/// a logger that doesn't do anything -/// -internal sealed class EmptyLogger : ILogger -{ - public static readonly EmptyLogger Instance = new(); - - public bool IsEnabled(FunctionId functionId) - => false; - - public void Log(FunctionId functionId, LogMessage logMessage) - { - } - - public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken) - { - } - - public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken) - { - } -} diff --git a/src/Workspaces/Core/Portable/Log/EtwLogger.cs b/src/Workspaces/Core/Portable/Log/EtwLogger.cs index fef6bf415270b..9dcbd7178da85 100644 --- a/src/Workspaces/Core/Portable/Log/EtwLogger.cs +++ b/src/Workspaces/Core/Portable/Log/EtwLogger.cs @@ -9,17 +9,37 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// A logger that publishes events to ETW using an EventSource. +/// A sink that publishes events to ETW using an EventSource. Opt-in: enabled per- +/// by a predicate that the host can swap at runtime (Tools -> Options -> Performance Loggers). It stays +/// registered for the lifetime of the process; "disabled" means the predicate rejects everything, which +/// is what keeps a second instance from ever being composed alongside this one and double-posting. /// -internal sealed class EtwLogger(Func isEnabledPredicate) : ILogger +internal sealed class EtwLogger : IEventSink { + /// + /// A predicate that rejects every . Used as the initial state for sinks + /// that are off until a user turns them on. + /// + public static readonly Func DisabledPredicate = static _ => false; // Due to ETW specifics, RoslynEventSource.Instance needs to be initialized during EtwLogger construction // so that we can enable the listeners synchronously before any events are logged. private readonly RoslynEventSource _source = RoslynEventSource.Instance; + private Func _isEnabledPredicate; + + public EtwLogger(Func isEnabledPredicate) + => _isEnabledPredicate = isEnabledPredicate; + + /// + /// Replaces the enablement predicate in place. Callers must refresh the composed instance rather + /// than constructing a competing one, or events would be posted twice. + /// + public void UpdatePredicate(Func isEnabledPredicate) + => Volatile.Write(ref _isEnabledPredicate, isEnabledPredicate); + public bool IsEnabled(FunctionId functionId) - => _source.IsEnabled() && isEnabledPredicate(functionId); + => _source.IsEnabled() && Volatile.Read(ref _isEnabledPredicate)(functionId); public void Log(FunctionId functionId, LogMessage logMessage) => _source.Log(GetMessage(logMessage), functionId); diff --git a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs new file mode 100644 index 0000000000000..f5641c2ff1aa1 --- /dev/null +++ b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; + +namespace Microsoft.CodeAnalysis.Internal.Log; + +/// +/// Property names that telemetry consumers depend on by string. Kept as constants so the emitted shape +/// is stable and greppable. +/// +internal static class TelemetryKeys +{ + public const string Name = "Name"; + public const string Value = "Value"; + public const string LanguageName = "LanguageName"; +} + +/// +/// The parts of that need , which lives in +/// the Workspaces layer rather than the dependency-minimal shared layer. +/// +internal static partial class RoslynTelemetry +{ + /// + /// Posts a discrete event carrying the wall-clock duration of the returned scope, but only if it + /// meets or exceeds . Unlike + /// this is not aggregated - each occurrence is + /// its own event. + /// + public static IDisposable? LogBlockTime(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs = -1) + => GetEventSink() is null ? null : new TimedEventBlock(functionId, logMessage, minThresholdMs); + + private sealed class TimedEventBlock : IDisposable + { + private readonly FunctionId _functionId; + private readonly KeyValueLogMessage _logMessage; + private readonly int _minThresholdMs; + private readonly int _tick; + + public TimedEventBlock(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs) + { + _functionId = functionId; + _logMessage = logMessage; + _minThresholdMs = minThresholdMs; + _tick = Environment.TickCount; + } + + public void Dispose() + { + // This delta is valid for durations of < 25 days + var elapsed = Environment.TickCount - _tick; + if (elapsed >= _minThresholdMs) + { + var logMessage = KeyValueLogMessage.Create(static (m, args) => + { + m[TelemetryKeys.Value] = (long)args.elapsed; + m.AddRange(args.properties); + }, (elapsed, properties: _logMessage.Properties)); + +#if DEBUG + logMessage.Free(); +#else + // Don't skew telemetry results by logging in debug bits or under debugger. + if (Debugger.IsAttached) + logMessage.Free(); + else + Log(_functionId, logMessage); +#endif + } + + _logMessage.Free(); + } + } +} diff --git a/src/Workspaces/Core/Portable/Log/TraceLogger.cs b/src/Workspaces/Core/Portable/Log/TraceLogger.cs index b11e3b83ef8ea..5b2b761509241 100644 --- a/src/Workspaces/Core/Portable/Log/TraceLogger.cs +++ b/src/Workspaces/Core/Portable/Log/TraceLogger.cs @@ -9,14 +9,23 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// Implementation of that produce timing debug output. +/// Implementation of that produces timing debug output. Opt-in, and controlled +/// the same way as : it stays registered and its predicate decides whether +/// anything is written. /// -internal sealed class TraceLogger(Func? isEnabledPredicate) : ILogger +internal sealed class TraceLogger : IEventSink { - public static readonly TraceLogger Instance = new(isEnabledPredicate: null); + private Func _isEnabledPredicate; + + public TraceLogger(Func isEnabledPredicate) + => _isEnabledPredicate = isEnabledPredicate; + + /// + public void UpdatePredicate(Func isEnabledPredicate) + => Volatile.Write(ref _isEnabledPredicate, isEnabledPredicate); public bool IsEnabled(FunctionId functionId) - => isEnabledPredicate == null || isEnabledPredicate(functionId); + => Volatile.Read(ref _isEnabledPredicate)(functionId); public void Log(FunctionId functionId, LogMessage logMessage) => Trace.WriteLine(string.Format("[{0}] {1} - {2}", Environment.CurrentManagedThreadId, functionId.ToString(), logMessage.GetMessage())); diff --git a/src/Workspaces/Core/Portable/Telemetry/ITelemetryBlockLog.cs b/src/Workspaces/Core/Portable/Telemetry/ITelemetryBlockLog.cs deleted file mode 100644 index 583e10c16f283..0000000000000 --- a/src/Workspaces/Core/Portable/Telemetry/ITelemetryBlockLog.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using Microsoft.CodeAnalysis.Internal.Log; - -namespace Microsoft.CodeAnalysis.Telemetry; - -internal interface ITelemetryBlockLog : ITelemetryLog -{ - /// - /// Adds an execution time telemetry event representing - /// only if block duration meets or exceeds milliseconds. - /// - /// Event data to be sent - /// Optional parameter used to determine whether to send the telemetry event (in milliseconds) - IDisposable? LogBlockTime(KeyValueLogMessage logMessage, int minThresholdMs = -1); -} diff --git a/src/Workspaces/Core/Portable/Telemetry/ITelemetryLog.cs b/src/Workspaces/Core/Portable/Telemetry/ITelemetryLog.cs deleted file mode 100644 index 147c2c916a0ef..0000000000000 --- a/src/Workspaces/Core/Portable/Telemetry/ITelemetryLog.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Microsoft.CodeAnalysis.Internal.Log; - -namespace Microsoft.CodeAnalysis.Telemetry; - -internal interface ITelemetryLog -{ - /// - /// Adds a telemetry event with values obtained from context message - /// - void Log(KeyValueLogMessage logMessage); -} diff --git a/src/Workspaces/Core/Portable/Telemetry/ITelemetryLogProvider.cs b/src/Workspaces/Core/Portable/Telemetry/ITelemetryLogProvider.cs deleted file mode 100644 index c670bdb1ebfd8..0000000000000 --- a/src/Workspaces/Core/Portable/Telemetry/ITelemetryLogProvider.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Microsoft.CodeAnalysis.Internal.Log; - -namespace Microsoft.CodeAnalysis.Telemetry; - -internal interface ITelemetryLogProvider -{ - /// - /// Returns an for logging telemetry. - /// - /// FunctionId representing the telemetry operation - ITelemetryBlockLog? GetLog(FunctionId functionId); - - /// - /// Returns an aggregating for logging histogram based telemetry. - /// - /// FunctionId representing the telemetry operation - /// Optional values indicating bucket boundaries in milliseconds. If not specified, - /// all aggregating events created will use a default configuration - ITelemetryBlockLog? GetHistogramLog(FunctionId functionId, double[]? bucketBoundaries = null); - - /// - /// Returns an aggregating for logging counter telemetry. - /// - /// FunctionId representing the telemetry operation - ITelemetryLog? GetCounterLog(FunctionId functionId); - - /// - /// Flushes all telemetry logs - /// - void Flush(); -} diff --git a/src/Workspaces/Core/Portable/Telemetry/TelemetryLogging.cs b/src/Workspaces/Core/Portable/Telemetry/TelemetryLogging.cs deleted file mode 100644 index 3a2b6bdb81233..0000000000000 --- a/src/Workspaces/Core/Portable/Telemetry/TelemetryLogging.cs +++ /dev/null @@ -1,149 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CodeAnalysis.ErrorReporting; -using Microsoft.CodeAnalysis.Internal.Log; - -namespace Microsoft.CodeAnalysis.Telemetry; - -/// -/// Provides access to posting telemetry events or adding information -/// to aggregated telemetry events. Posts pending telemetry at 30 -/// minute intervals. -/// -internal static class TelemetryLogging -{ - private static ITelemetryLogProvider? s_logProvider; - - public const string KeyName = "Name"; - public const string KeyValue = "Value"; - public const string KeyLanguageName = "LanguageName"; - public const string KeyMetricName = "MetricName"; - - public static void SetLogProvider(ITelemetryLogProvider logProvider) - { - s_logProvider = logProvider; - - _ = PostCollectedTelemetryAsync(CancellationToken.None); - } - - /// - /// Posts a telemetry event representing the operation with context message - /// - public static void Log(FunctionId functionId, KeyValueLogMessage logMessage) - { - GetLog(functionId)?.Log(logMessage); - - logMessage.Free(); - } - - /// - /// Posts a telemetry event representing the operation - /// only if the block duration meets or exceeds milliseconds. - /// This event will contain properties from and the actual execution time. - /// - /// Properties to be set on the telemetry event - /// Optional parameter used to determine whether to send the telemetry event - public static IDisposable? LogBlockTime(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs = -1) - { - return GetLog(functionId)?.LogBlockTime(logMessage, minThresholdMs); - } - - /// - /// Adds information to an aggregated telemetry event representing the operation - /// with the specified name and value. - /// - public static void LogAggregatedHistogram(FunctionId functionId, TelemetryLoggingInterpolatedStringHandler name, long value) - { - if (GetHistogramLog(functionId) is not { } aggregatingLog) - return; - - var logMessage = KeyValueLogMessage.Create(static (m, args) => - { - var (name, value) = args; - m[KeyName] = name.GetFormattedText(); - m[KeyValue] = value; - }, (name, value)); - - aggregatingLog.Log(logMessage); - logMessage.Free(); - } - - public static void LogAggregatedHistogram(FunctionId functionId, KeyValueLogMessage logMessage) - { - if (GetHistogramLog(functionId) is not { } aggregatingLog) - return; - - aggregatingLog.Log(logMessage); - logMessage.Free(); - } - - /// - /// Adds block execution time to an aggregated telemetry event representing the operation - /// with metric only if the block duration meets or exceeds milliseconds. - /// - /// Optional parameter used to determine whether to send the telemetry event - public static IDisposable? LogBlockTimeAggregatedHistogram(FunctionId functionId, TelemetryLoggingInterpolatedStringHandler metricName, int minThresholdMs = -1) - { - if (GetHistogramLog(functionId) is not { } aggregatingLog) - return null; - - var logMessage = KeyValueLogMessage.Create(static (m, metricName) => - { - m[KeyName] = metricName.GetFormattedText(); - }, metricName); - - return aggregatingLog.LogBlockTime(logMessage, minThresholdMs); - } - - public static void LogAggregatedCounter(FunctionId functionId, KeyValueLogMessage logMessage) - { - if (GetCounterLog(functionId) is not { } aggregatingLog) - return; - - aggregatingLog.Log(logMessage); - logMessage.Free(); - } - - /// - /// Returns non-aggregating telemetry log. - /// - public static ITelemetryBlockLog? GetLog(FunctionId functionId) - { - return s_logProvider?.GetLog(functionId); - } - - /// - /// Returns aggregating telemetry log. - /// - private static ITelemetryBlockLog? GetHistogramLog(FunctionId functionId, double[]? bucketBoundaries = null) - { - return s_logProvider?.GetHistogramLog(functionId, bucketBoundaries); - } - - private static ITelemetryLog? GetCounterLog(FunctionId functionId) - { - return s_logProvider?.GetCounterLog(functionId); - } - - public static void Flush() - { - s_logProvider?.Flush(); - } - - private static async Task PostCollectedTelemetryAsync(CancellationToken cancellationToken) - { - await Task.Delay(TimeSpan.FromMinutes(30), cancellationToken).ConfigureAwait(false); - - Flush(); - - // Create a fire and forget task to handle the next collection. This doesn't use IAsynchronousOperationListener - // to track this work as no-one needs to ensure this is sent, and the create a new item of work - // upon previous completion doesn't fit well in that model. - _ = PostCollectedTelemetryAsync(CancellationToken.None).ReportNonFatalErrorAsync(); - } -} diff --git a/src/Workspaces/Core/Portable/Telemetry/TelemetryLoggingInterpolatedStringHandler.cs b/src/Workspaces/Core/Portable/Telemetry/TelemetryLoggingInterpolatedStringHandler.cs deleted file mode 100644 index e4bd2630a570a..0000000000000 --- a/src/Workspaces/Core/Portable/Telemetry/TelemetryLoggingInterpolatedStringHandler.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Runtime.CompilerServices; -using System.Text; - -namespace Microsoft.CodeAnalysis.Telemetry; - -[InterpolatedStringHandler] -internal readonly struct TelemetryLoggingInterpolatedStringHandler -{ - private readonly StringBuilder _stringBuilder; - - public TelemetryLoggingInterpolatedStringHandler(int literalLength, int _) - { - _stringBuilder = new StringBuilder(capacity: literalLength); - } - - public void AppendLiteral(string value) => _stringBuilder.Append(value); - - public void AppendFormatted(T value) => _stringBuilder.Append(value?.ToString()); - - public string GetFormattedText() => _stringBuilder.ToString(); -} diff --git a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs index 8974916709a82..c7e8a3f90acc3 100644 --- a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs +++ b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs @@ -101,7 +101,7 @@ public override void After(MethodInfo? methodUnderTest) // Reset static state variables. _hostServices = null; ExportProviderCache.SetEnabled_OnlyUseExportProviderAttributeCanCall(false); - Logger.SetLogger(null); + RoslynTelemetry.SetEventSink(null); } } diff --git a/src/Workspaces/Remote/ServiceHub/Services/AssetSynchronization/RemoteAssetSynchronizationService.cs b/src/Workspaces/Remote/ServiceHub/Services/AssetSynchronization/RemoteAssetSynchronizationService.cs index 26bf388a88ef1..8c7bfee5a0043 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/AssetSynchronization/RemoteAssetSynchronizationService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/AssetSynchronization/RemoteAssetSynchronizationService.cs @@ -64,14 +64,7 @@ public ValueTask SynchronizeTextChangesAsync( var wasSynchronized = await SynchronizeTextChangesHelperAsync().ConfigureAwait(false); var metricName = wasSynchronized ? SynchronizeTextChangesAsyncSucceededMetricName : SynchronizeTextChangesAsyncFailedMetricName; - var keyName = wasSynchronized ? SynchronizeTextChangesAsyncSucceededKeyName : SynchronizeTextChangesAsyncFailedKeyName; - TelemetryLogging.LogAggregatedCounter(FunctionId.RemoteHostService_SynchronizeTextAsyncStatus, KeyValueLogMessage.Create(static (m, args) => - { - var (keyName, metricName) = args; - m[TelemetryLogging.KeyName] = keyName; - m[TelemetryLogging.KeyValue] = 1L; - m[TelemetryLogging.KeyMetricName] = metricName; - }, (keyName, metricName))); + RoslynTelemetry.Count(FunctionId.RemoteHostService_SynchronizeTextAsyncStatus, metricName, 1); return; diff --git a/src/Workspaces/Remote/ServiceHub/Services/DiagnosticAnalyzer/PerformanceTrackerService.cs b/src/Workspaces/Remote/ServiceHub/Services/DiagnosticAnalyzer/PerformanceTrackerService.cs index 1bd8bb08d3f95..bc0a06c46cc95 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/DiagnosticAnalyzer/PerformanceTrackerService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/DiagnosticAnalyzer/PerformanceTrackerService.cs @@ -68,7 +68,7 @@ public void AddSnapshot(IEnumerable snapshot, int unitC var delay = (long)perfInfo.TimeSpan.TotalMilliseconds; - TelemetryLogging.LogAggregatedHistogram(FunctionId.PerformAnalysis_Summary, $"IndividualTimes", delay); + RoslynTelemetry.Record(FunctionId.PerformAnalysis_Summary, "IndividualTimes", delay); if (delay > PerformAnalysisTelemetryDelay) { @@ -85,7 +85,7 @@ public void AddSnapshot(IEnumerable snapshot, int unitC m[ForSpanAnalysis] = forSpanAnalysis; }); - TelemetryLogging.Log(FunctionId.PerformAnalysis_Delay, logMessage); + RoslynTelemetry.Log(FunctionId.PerformAnalysis_Delay, logMessage); } } diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs index e94106ad3edc3..5a97ef449fbc7 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs @@ -80,21 +80,12 @@ public ValueTask EnableLoggingAsync(ImmutableArray loggerTypeNames, Immu var functionIdsSet = new HashSet(functionIds); bool logChecker(FunctionId id) => functionIdsSet.Contains(id); - // we only support 2 types of loggers - SetRoslynLogger(loggerTypeNames, () => new EtwLogger(logChecker)); - SetRoslynLogger(loggerTypeNames, () => new TraceLogger(logChecker)); + // Mirrors the VS side: the sinks are composed once and only their enablement changes here. + var telemetryService = (RemoteWorkspaceTelemetryService)GetWorkspace().Services.GetRequiredService(); + telemetryService.UpdateDiagnosticSinkEnablement( + etwEnabled: loggerTypeNames.Contains(nameof(EtwLogger)), + traceEnabled: loggerTypeNames.Contains(nameof(TraceLogger)), + logChecker); }, cancellationToken); } - - private static void SetRoslynLogger(ImmutableArray loggerTypes, Func creator) where T : ILogger - { - if (loggerTypes.Contains(typeof(T).Name)) - { - RoslynLogger.SetLogger(AggregateLogger.AddOrReplace(creator(), RoslynLogger.GetLogger(), l => l is T)); - } - else - { - RoslynLogger.SetLogger(AggregateLogger.Remove(RoslynLogger.GetLogger(), l => l is T)); - } - } } diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs index 2444da5f4ffab..0c142a4276a12 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs @@ -16,8 +16,29 @@ namespace Microsoft.VisualStudio.LanguageServices.Telemetry; [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] internal sealed class RemoteWorkspaceTelemetryService() : AbstractWorkspaceTelemetryService { - protected override ILogger CreateLogger(TelemetrySession telemetrySession, bool logDelta) - => AggregateLogger.Create( + /// + /// Opt-in diagnostic sinks, mirroring the VS host. Composed once and thereafter toggled through + /// their predicates by IRemoteProcessTelemetryService.EnableLoggingAsync. + /// + private EtwLogger? _etwLogger; + private TraceLogger? _traceLogger; + + protected override IEventSink CreateLogger(TelemetrySession telemetrySession, bool logDelta) + { + _etwLogger = new EtwLogger(EtwLogger.DisabledPredicate); + _traceLogger = new TraceLogger(EtwLogger.DisabledPredicate); + + return AggregateEventSink.Create( + _etwLogger, + _traceLogger, TelemetryLogger.Create(telemetrySession, logDelta), - Logger.GetLogger()); + RoslynTelemetry.GetEventSink()); + } + + /// + internal void UpdateDiagnosticSinkEnablement(bool etwEnabled, bool traceEnabled, Func isEnabled) + { + _etwLogger?.UpdatePredicate(etwEnabled ? isEnabled : EtwLogger.DisabledPredicate); + _traceLogger?.UpdatePredicate(traceEnabled ? isEnabled : EtwLogger.DisabledPredicate); + } } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems index 0c668a608a5fd..16e248a5e02d4 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems @@ -1,4 +1,4 @@ - + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) @@ -332,10 +332,16 @@ - + + + + + + + - + diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs new file mode 100644 index 0000000000000..923b05d205cb3 --- /dev/null +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; + +namespace Microsoft.CodeAnalysis.Internal.Log; + +/// +/// Fans an event out to a fixed set of sinks. The set is decided once, when a host composes its +/// telemetry, and is not mutated afterwards: turning a sink off is that sink's own +/// returning false, not its removal from this list. That keeps a +/// sink from being registered twice (which would post its events twice) and removes the need for the +/// predicate-based add/replace/remove that used to live here. +/// +internal sealed class AggregateEventSink : IEventSink +{ + private readonly ImmutableArray _sinks; + + private AggregateEventSink(ImmutableArray sinks) + => _sinks = sinks; + + public static AggregateEventSink Create(params IEventSink?[] sinks) + { + var set = new HashSet(); + + // flatten nested aggregates so a sink can never appear twice + foreach (var sink in sinks) + { + if (sink is null) + continue; + + if (sink is AggregateEventSink aggregate) + { + set.UnionWith(aggregate._sinks); + continue; + } + + set.Add(sink); + } + + return new AggregateEventSink([.. set]); + } + + public bool IsEnabled(FunctionId functionId) + => true; + + public void Log(FunctionId functionId, LogMessage logMessage) + { + for (var i = 0; i < _sinks.Length; i++) + { + var sink = _sinks[i]; + if (!sink.IsEnabled(functionId)) + { + continue; + } + + sink.Log(functionId, logMessage); + } + } + + public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken) + { + for (var i = 0; i < _sinks.Length; i++) + { + var sink = _sinks[i]; + if (!sink.IsEnabled(functionId)) + { + continue; + } + + sink.LogBlockStart(functionId, logMessage, uniquePairId, cancellationToken); + } + } + + public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken) + { + for (var i = 0; i < _sinks.Length; i++) + { + var sink = _sinks[i]; + if (!sink.IsEnabled(functionId)) + { + continue; + } + + sink.LogBlockEnd(functionId, logMessage, uniquePairId, delta, cancellationToken); + } + } +} diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/ILogger.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs similarity index 55% rename from src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/ILogger.cs rename to src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs index cad7e8782d53b..d9060b4c5a70c 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/ILogger.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs @@ -7,27 +7,30 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// logger interface actual logger should implements +/// A destination for discrete events and scopes identified by . +/// Implementations decide, via , whether anything is recorded at all; +/// that is where consent (for telemetry sinks) and opt-in enablement (for diagnostic sinks) live. /// -internal interface ILogger +internal interface IEventSink { /// - /// answer whether it is enabled or not for the specific function id + /// Whether this sink will record anything for . Checked before any + /// is constructed, so returning false makes logging allocation-free. /// bool IsEnabled(FunctionId functionId); /// - /// log a specific event with context message + /// Record a discrete event with context message. /// void Log(FunctionId functionId, LogMessage logMessage); /// - /// log a start event with context message + /// Record the start of a scope with context message. /// void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken); /// - /// log an end event + /// Record the end of a scope. /// void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken); -} +} \ No newline at end of file diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs new file mode 100644 index 0000000000000..0b249dbc6eb75 --- /dev/null +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.CodeAnalysis.Internal.Log; + +/// +/// A destination for aggregated measurements. Implementations accumulate values in memory and post +/// them in batches when is called. +/// +/// The contract is deliberately free of any telemetry-backend or BCL-metrics types so that it can live +/// in the dependency-minimal shared layer, and deliberately keyed by a plain +/// string rather than so that Razor - which has no - +/// can share the same implementation. Roslyn's -to-event-name mapping happens +/// one level up, in . +/// +/// +/// The tag parameter mirrors System.Diagnostics.Metrics.Counter<T>.Add exactly, so that +/// recording can later be moved onto BCL metric instruments without touching any call site. +/// +/// +internal interface IMetricSink +{ + /// + /// Adds to a monotonically increasing counter. + /// + void Count(string eventName, string metricName, long delta, ReadOnlySpan> tags); + + /// + /// Records as one observation in a distribution. + /// + void Record(string eventName, string metricName, long value, ReadOnlySpan> tags); + + /// + /// Posts everything accumulated so far and resets. Safe to call at any time and from any thread; + /// hosts call it periodically, at shutdown, and whenever a logical session ends. + /// + void Flush(); +} diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs index 1c697130eabf3..5f57df67b42ed 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs @@ -3,220 +3,80 @@ // See the LICENSE file in the project root for more information. using System; -using System.Diagnostics.CodeAnalysis; using System.Threading; -#if !CODE_STYLE -using System.Linq; -using Microsoft.CodeAnalysis.Options; -#endif - namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// provide a way to log activities to various back end such as etl, code marker and etc +/// Temporary forwarding shim onto , kept so that this rename did not have to +/// touch the 150+ existing Logger.Log / Logger.LogBlock call sites in one change. New +/// code should call directly. +/// +/// This type is intended to be deleted once its call sites have been mechanically updated; see +/// https://github.com/dotnet/roslyn/issues/ for the tracking issue. It deliberately carries no +/// because the repository builds with warnings as errors and the +/// remaining call sites are expected, not accidental. +/// /// -internal static partial class Logger +internal static class Logger { - private static ILogger? s_currentLogger; - - /// - /// next unique block id that will be given to each LogBlock - /// - private static int s_lastUniqueBlockId; - - /// - /// give a way to explicitly set/replace the logger - /// - public static ILogger? SetLogger(ILogger? logger) - { - // we don't care what was there already, just replace it explicitly - return Interlocked.Exchange(ref s_currentLogger, logger); - } - - /// - /// ensure we have a logger by putting one from workspace service if one is not there already. - /// - public static ILogger? GetLogger() - => s_currentLogger; - - private static bool TryGetActiveLogger(FunctionId functionId, [NotNullWhen(true)] out ILogger? activeLogger) - { - var logger = s_currentLogger; - if (logger == null || !logger.IsEnabled(functionId)) - { - activeLogger = null; - return false; - } - - activeLogger = logger; - return true; - } - - /// - /// log a specific event with a simple context message which should be very cheap to create - /// + /// public static void Log(FunctionId functionId, string? message = null, LogLevel logLevel = LogLevel.Debug) - { - if (TryGetActiveLogger(functionId, out var logger)) - { - logger.Log(functionId, LogMessage.Create(message ?? "", logLevel: logLevel)); - } - } - - /// - /// log a specific event with a context message that will only be created when it is needed. - /// the messageGetter should be cheap to create. in another word, it shouldn't capture any locals - /// + => RoslynTelemetry.Log(functionId, message, logLevel); + + /// public static void Log(FunctionId functionId, Func messageGetter, LogLevel logLevel = LogLevel.Debug) - { - if (TryGetActiveLogger(functionId, out var logger)) - { - var logMessage = LogMessage.Create(messageGetter, logLevel); - logger.Log(functionId, logMessage); - - logMessage.Free(); - } - } - - /// - /// log a specific event with a context message that requires some arguments to be created when requested. - /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals - /// + => RoslynTelemetry.Log(functionId, messageGetter, logLevel); + + /// public static void Log(FunctionId functionId, Func messageGetter, TArg arg, LogLevel logLevel = LogLevel.Debug) - { - if (TryGetActiveLogger(functionId, out var logger)) - { - var logMessage = LogMessage.Create(messageGetter, arg, logLevel); - logger.Log(functionId, logMessage); - logMessage.Free(); - } - } - - /// - /// log a specific event with a context message that requires some arguments to be created when requested. - /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals - /// + => RoslynTelemetry.Log(functionId, messageGetter, arg, logLevel); + + /// public static void Log(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, LogLevel logLevel = LogLevel.Debug) - { - if (TryGetActiveLogger(functionId, out var logger)) - { - var logMessage = LogMessage.Create(messageGetter, arg0, arg1, logLevel); - logger.Log(functionId, logMessage); - logMessage.Free(); - } - } - - /// - /// log a specific event with a context message that requires some arguments to be created when requested. - /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals - /// + => RoslynTelemetry.Log(functionId, messageGetter, arg0, arg1, logLevel); + + /// public static void Log(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, LogLevel logLevel = LogLevel.Debug) - { - if (TryGetActiveLogger(functionId, out var logger)) - { - var logMessage = LogMessage.Create(messageGetter, arg0, arg1, arg2, logLevel); - logger.Log(functionId, logMessage); - logMessage.Free(); - } - } - - /// - /// log a specific event with a context message that requires some arguments to be created when requested. - /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals - /// + => RoslynTelemetry.Log(functionId, messageGetter, arg0, arg1, arg2, logLevel); + + /// public static void Log(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, TArg3 arg3, LogLevel logLevel = LogLevel.Debug) - { - if (TryGetActiveLogger(functionId, out var logger)) - { - var logMessage = LogMessage.Create(messageGetter, arg0, arg1, arg2, arg3, logLevel); - logger.Log(functionId, logMessage); - logMessage.Free(); - } - } - - /// - /// log a specific event with a context message. - /// + => RoslynTelemetry.Log(functionId, messageGetter, arg0, arg1, arg2, arg3, logLevel); + + /// public static void Log(FunctionId functionId, LogMessage logMessage) - { - if (TryGetActiveLogger(functionId, out var logger)) - { - logger.Log(functionId, logMessage); - logMessage.Free(); - } - } - - /// - /// return next unique pair id - /// - private static int GetNextUniqueBlockId() - => Interlocked.Increment(ref s_lastUniqueBlockId); - - /// - /// simplest way to log a start and end pair - /// + => RoslynTelemetry.Log(functionId, logMessage); + + /// public static IDisposable LogBlock(FunctionId functionId, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => LogBlock(functionId, string.Empty, token, logLevel); + => RoslynTelemetry.LogBlock(functionId, token, logLevel); - /// - /// simplest way to log a start and end pair with a simple context message which should be very cheap to create - /// + /// public static IDisposable LogBlock(FunctionId functionId, string? message, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveLogger(functionId, out var logger) - ? CreateLogBlock(logger, functionId, LogMessage.Create(message ?? "", logLevel), GetNextUniqueBlockId(), token) - : EmptyLogBlock.Instance; - - /// - /// log a start and end pair with a context message that will only be created when it is needed. - /// the messageGetter should be cheap to create. in another word, it shouldn't capture any locals - /// + => RoslynTelemetry.LogBlock(functionId, message, token, logLevel); + + /// public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveLogger(functionId, out var logger) - ? CreateLogBlock(logger, functionId, LogMessage.Create(messageGetter, logLevel), GetNextUniqueBlockId(), token) - : EmptyLogBlock.Instance; - - /// - /// log a start and end pair with a context message that requires some arguments to be created when requested. - /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals - /// + => RoslynTelemetry.LogBlock(functionId, messageGetter, token, logLevel); + + /// public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg arg, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveLogger(functionId, out var logger) - ? CreateLogBlock(logger, functionId, LogMessage.Create(messageGetter, arg, logLevel), GetNextUniqueBlockId(), token) - : EmptyLogBlock.Instance; - - /// - /// log a start and end pair with a context message that requires some arguments to be created when requested. - /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals - /// + => RoslynTelemetry.LogBlock(functionId, messageGetter, arg, token, logLevel); + + /// public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveLogger(functionId, out var logger) - ? CreateLogBlock(logger, functionId, LogMessage.Create(messageGetter, arg0, arg1, logLevel), GetNextUniqueBlockId(), token) - : EmptyLogBlock.Instance; - - /// - /// log a start and end pair with a context message that requires some arguments to be created when requested. - /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals - /// + => RoslynTelemetry.LogBlock(functionId, messageGetter, arg0, arg1, token, logLevel); + + /// public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveLogger(functionId, out var logger) - ? CreateLogBlock(logger, functionId, LogMessage.Create(messageGetter, arg0, arg1, arg2, logLevel), GetNextUniqueBlockId(), token) - : EmptyLogBlock.Instance; - - /// - /// log a start and end pair with a context message that requires some arguments to be created when requested. - /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals - /// + => RoslynTelemetry.LogBlock(functionId, messageGetter, arg0, arg1, arg2, token, logLevel); + + /// public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, TArg3 arg3, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveLogger(functionId, out var logger) - ? CreateLogBlock(logger, functionId, LogMessage.Create(messageGetter, arg0, arg1, arg2, arg3, logLevel), GetNextUniqueBlockId(), token) - : EmptyLogBlock.Instance; + => RoslynTelemetry.LogBlock(functionId, messageGetter, arg0, arg1, arg2, arg3, token, logLevel); - /// - /// log a start and end pair with a context message. - /// + /// public static IDisposable LogBlock(FunctionId functionId, LogMessage logMessage, CancellationToken token) - => TryGetActiveLogger(functionId, out var logger) - ? CreateLogBlock(logger, functionId, logMessage, GetNextUniqueBlockId(), token) - : EmptyLogBlock.Instance; + => RoslynTelemetry.LogBlock(functionId, logMessage, token); } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.LogBlock.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs similarity index 73% rename from src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.LogBlock.cs rename to src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs index 53d457e1b6aa6..ae9f8e29a6064 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.LogBlock.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs @@ -9,19 +9,19 @@ namespace Microsoft.CodeAnalysis.Internal.Log; -internal static partial class Logger +internal static partial class RoslynTelemetry { // Regardless of how many tasks we can run in parallel on the machine, we likely won't need more than 256 // instrumentation points in flight at a given time. // Use an object pool since we may be logging up to 1-10k events/second private static readonly ObjectPool s_pool = new(() => new RoslynLogBlock(s_pool!), Math.Min(Environment.ProcessorCount * 8, 256)); - public static IDisposable CreateLogBlock(ILogger logger, FunctionId functionId, LogMessage message, int blockId, CancellationToken cancellationToken) + public static IDisposable CreateLogBlock(IEventSink sink, FunctionId functionId, LogMessage message, int blockId, CancellationToken cancellationToken) { - Contract.ThrowIfNull(logger); + Contract.ThrowIfNull(sink); var block = s_pool.Allocate(); - block.Construct(logger, functionId, message, blockId, cancellationToken); + block.Construct(sink, functionId, message, blockId, cancellationToken); return block; } @@ -33,7 +33,7 @@ private sealed class RoslynLogBlock(ObjectPool pool) : IDisposab { // these need to be cleared before putting back to pool - private ILogger? _logger; + private IEventSink? _sink; private LogMessage? _logMessage; private CancellationToken _cancellationToken; @@ -41,21 +41,21 @@ private sealed class RoslynLogBlock(ObjectPool pool) : IDisposab private int _tick; private int _blockId; - public void Construct(ILogger logger, FunctionId functionId, LogMessage logMessage, int blockId, CancellationToken cancellationToken) + public void Construct(IEventSink sink, FunctionId functionId, LogMessage logMessage, int blockId, CancellationToken cancellationToken) { - _logger = logger; + _sink = sink; _functionId = functionId; _logMessage = logMessage; _tick = Environment.TickCount; _blockId = blockId; _cancellationToken = cancellationToken; - logger.LogBlockStart(functionId, logMessage, blockId, cancellationToken); + sink.LogBlockStart(functionId, logMessage, blockId, cancellationToken); } public void Dispose() { - if (_logger == null) + if (_sink == null) { return; } @@ -65,12 +65,12 @@ public void Dispose() // This delta is valid for durations of < 25 days var delta = Environment.TickCount - _tick; - _logger.LogBlockEnd(_functionId, _logMessage, _blockId, delta, _cancellationToken); + _sink.LogBlockEnd(_functionId, _logMessage, _blockId, delta, _cancellationToken); // Free this block back to the pool _logMessage.Free(); _logMessage = null; - _logger = null; + _sink = null; _cancellationToken = default; pool.Free(this); diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs new file mode 100644 index 0000000000000..b9f181d597e15 --- /dev/null +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -0,0 +1,179 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.CodeAnalysis.Internal.Log; + +internal static partial class RoslynTelemetry +{ + private static IMetricSink? s_currentMetricSink; + + private static readonly AsyncLocal t_ambientSessionKey = new(); + + /// + /// Whether consults ambient state. Only a host that actually runs + /// more than one logical session per process turns this on; leaving it off keeps the per-record + /// cost at a single static bool read. + /// + private static bool s_ambientRoutingEnabled; + + /// + /// The session that measurements recorded on this thread belong to. + /// + /// Ambient routing is not enabled today, so this is always . + /// The seam exists so that implementations bucket by session from the + /// start; enabling it later is a matter of setting and pushing + /// keys around the work that belongs to each session, with no change to any call site or to any sink. + /// + /// + internal static TelemetrySessionKey CurrentSessionKey + => s_ambientRoutingEnabled ? (t_ambientSessionKey.Value ?? TelemetrySessionKey.Default) : TelemetrySessionKey.Default; + + /// + /// Replaces the active metric sink. Hosts call this once during startup; tests reset it to + /// during teardown. + /// + public static IMetricSink? SetMetricSink(IMetricSink? sink) + => Interlocked.Exchange(ref s_currentMetricSink, sink); + + public static IMetricSink? GetMetricSink() + => s_currentMetricSink; + + /// + /// Posts all pending aggregated measurements. Called on a timer, at shutdown, and when a logical + /// session ends. Every session's accumulated data is posted to its own session; it is safe (and + /// intentional) for this to flush more than the caller's own session. + /// + public static void Flush() + => s_currentMetricSink?.Flush(); + + #region Counters + + public static void Count(FunctionId functionId, string metricName, long delta = 1) + { + if (s_currentMetricSink is { } sink) + sink.Count(TelemetryNaming.GetEventName(functionId), metricName, delta, default); + } + + public static void Count(FunctionId functionId, string metricName, long delta, KeyValuePair tag) + { + if (s_currentMetricSink is { } sink) + { + Span> tags = [tag]; + sink.Count(TelemetryNaming.GetEventName(functionId), metricName, delta, tags); + } + } + + public static void Count(FunctionId functionId, string metricName, long delta, KeyValuePair tag1, KeyValuePair tag2) + { + if (s_currentMetricSink is { } sink) + { + Span> tags = [tag1, tag2]; + sink.Count(TelemetryNaming.GetEventName(functionId), metricName, delta, tags); + } + } + + public static void Count(FunctionId functionId, string metricName, long delta, KeyValuePair tag1, KeyValuePair tag2, KeyValuePair tag3) + { + if (s_currentMetricSink is { } sink) + { + Span> tags = [tag1, tag2, tag3]; + sink.Count(TelemetryNaming.GetEventName(functionId), metricName, delta, tags); + } + } + + private static void CountCore(FunctionId functionId, string metricName, long delta, ReadOnlySpan> tags) + { + if (s_currentMetricSink is { } sink) + sink.Count(TelemetryNaming.GetEventName(functionId), metricName, delta, tags); + } + + #endregion + + #region Distributions + + public static void Record(FunctionId functionId, string metricName, long value) + { + if (s_currentMetricSink is { } sink) + sink.Record(TelemetryNaming.GetEventName(functionId), metricName, value, default); + } + + public static void Record(FunctionId functionId, string metricName, long value, KeyValuePair tag) + { + if (s_currentMetricSink is { } sink) + { + Span> tags = [tag]; + sink.Record(TelemetryNaming.GetEventName(functionId), metricName, value, tags); + } + } + + public static void Record(FunctionId functionId, string metricName, long value, KeyValuePair tag1, KeyValuePair tag2) + { + if (s_currentMetricSink is { } sink) + { + Span> tags = [tag1, tag2]; + sink.Record(TelemetryNaming.GetEventName(functionId), metricName, value, tags); + } + } + + public static void Record(FunctionId functionId, string metricName, long value, KeyValuePair tag1, KeyValuePair tag2, KeyValuePair tag3) + { + if (s_currentMetricSink is { } sink) + { + Span> tags = [tag1, tag2, tag3]; + sink.Record(TelemetryNaming.GetEventName(functionId), metricName, value, tags); + } + } + + private static void RecordCore(FunctionId functionId, string metricName, long value, ReadOnlySpan> tags) + { + if (s_currentMetricSink is { } sink) + sink.Record(TelemetryNaming.GetEventName(functionId), metricName, value, tags); + } + + #endregion + + /// + /// Records the wall-clock duration of the returned scope into a distribution, but only if it meets + /// or exceeds . Returns when no metric sink + /// is configured, so callers can using the result unconditionally. + /// + public static IDisposable? RecordBlockTime(FunctionId functionId, string metricName, int minThresholdMs = -1) + => s_currentMetricSink is null ? null : new TimedBlock(functionId, metricName, minThresholdMs, default); + + /// + public static IDisposable? RecordBlockTime(FunctionId functionId, string metricName, int minThresholdMs, params KeyValuePair[] tags) + => s_currentMetricSink is null ? null : new TimedBlock(functionId, metricName, minThresholdMs, tags); + + private sealed class TimedBlock : IDisposable + { + private readonly FunctionId _functionId; + private readonly string _metricName; + private readonly int _minThresholdMs; + private readonly KeyValuePair[]? _tags; + private readonly int _tick; + + public TimedBlock(FunctionId functionId, string metricName, int minThresholdMs, KeyValuePair[]? tags) + { + _functionId = functionId; + _metricName = metricName; + _minThresholdMs = minThresholdMs; + _tags = tags; + _tick = Environment.TickCount; + } + + public void Dispose() + { + // This delta is valid for durations of < 25 days + var delta = Environment.TickCount - _tick; + if (delta < _minThresholdMs) + return; + + RecordCore(_functionId, _metricName, delta, _tags is null ? default : _tags.AsSpan()); + } + } +} diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs new file mode 100644 index 0000000000000..e48393df25628 --- /dev/null +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -0,0 +1,239 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; + +namespace Microsoft.CodeAnalysis.Internal.Log; + +/// +/// Roslyn's telemetry entry point. Discrete events and scopes are recorded here and fan out to the +/// host's configured ; aggregated measurements go to its . +/// +/// A host configures this once at startup (see / ). +/// With nothing configured every method is a cheap no-op, which is what the build server, the CodeStyle +/// packages, and most tests rely on. +/// +/// +internal static partial class RoslynTelemetry +{ + private static IEventSink? s_currentEventSink; + + /// + /// next unique block id that will be given to each LogBlock + /// + private static int s_lastUniqueBlockId; + + /// + /// Replaces the active event sink. Hosts call this once during startup; tests reset it to + /// during teardown. + /// + public static IEventSink? SetEventSink(IEventSink? sink) + { + // we don't care what was there already, just replace it explicitly + return Interlocked.Exchange(ref s_currentEventSink, sink); + } + + public static IEventSink? GetEventSink() + => s_currentEventSink; + + /// + /// Atomically adds alongside whatever is already registered. Used by + /// diagnostic sinks that live in assemblies the composition root cannot reference (the diagnostics + /// tool window, integration tests), which attach once and are thereafter controlled by their own + /// rather than by being detached. + /// + public static void AddEventSink(IEventSink sink) + { + while (true) + { + var existing = s_currentEventSink; + var combined = existing is null ? sink : AggregateEventSink.Create(existing, sink); + if (Interlocked.CompareExchange(ref s_currentEventSink, combined, existing) == existing) + return; + } + } + + private static bool TryGetActiveEventSink(FunctionId functionId, [NotNullWhen(true)] out IEventSink? activeSink) + { + var sink = s_currentEventSink; + if (sink == null || !sink.IsEnabled(functionId)) + { + activeSink = null; + return false; + } + + activeSink = sink; + return true; + } + + + /// + /// log a specific event with a simple context message which should be very cheap to create + /// + public static void Log(FunctionId functionId, string? message = null, LogLevel logLevel = LogLevel.Debug) + { + if (TryGetActiveEventSink(functionId, out var sink)) + { + sink.Log(functionId, LogMessage.Create(message ?? "", logLevel: logLevel)); + } + } + + /// + /// log a specific event with a context message that will only be created when it is needed. + /// the messageGetter should be cheap to create. in another word, it shouldn't capture any locals + /// + public static void Log(FunctionId functionId, Func messageGetter, LogLevel logLevel = LogLevel.Debug) + { + if (TryGetActiveEventSink(functionId, out var sink)) + { + var logMessage = LogMessage.Create(messageGetter, logLevel); + sink.Log(functionId, logMessage); + + logMessage.Free(); + } + } + + /// + /// log a specific event with a context message that requires some arguments to be created when requested. + /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals + /// + public static void Log(FunctionId functionId, Func messageGetter, TArg arg, LogLevel logLevel = LogLevel.Debug) + { + if (TryGetActiveEventSink(functionId, out var sink)) + { + var logMessage = LogMessage.Create(messageGetter, arg, logLevel); + sink.Log(functionId, logMessage); + logMessage.Free(); + } + } + + /// + /// log a specific event with a context message that requires some arguments to be created when requested. + /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals + /// + public static void Log(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, LogLevel logLevel = LogLevel.Debug) + { + if (TryGetActiveEventSink(functionId, out var sink)) + { + var logMessage = LogMessage.Create(messageGetter, arg0, arg1, logLevel); + sink.Log(functionId, logMessage); + logMessage.Free(); + } + } + + /// + /// log a specific event with a context message that requires some arguments to be created when requested. + /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals + /// + public static void Log(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, LogLevel logLevel = LogLevel.Debug) + { + if (TryGetActiveEventSink(functionId, out var sink)) + { + var logMessage = LogMessage.Create(messageGetter, arg0, arg1, arg2, logLevel); + sink.Log(functionId, logMessage); + logMessage.Free(); + } + } + + /// + /// log a specific event with a context message that requires some arguments to be created when requested. + /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals + /// + public static void Log(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, TArg3 arg3, LogLevel logLevel = LogLevel.Debug) + { + if (TryGetActiveEventSink(functionId, out var sink)) + { + var logMessage = LogMessage.Create(messageGetter, arg0, arg1, arg2, arg3, logLevel); + sink.Log(functionId, logMessage); + logMessage.Free(); + } + } + + /// + /// log a specific event with a context message. + /// + public static void Log(FunctionId functionId, LogMessage logMessage) + { + if (TryGetActiveEventSink(functionId, out var sink)) + { + sink.Log(functionId, logMessage); + logMessage.Free(); + } + } + + /// + /// return next unique pair id + /// + private static int GetNextUniqueBlockId() + => Interlocked.Increment(ref s_lastUniqueBlockId); + + /// + /// simplest way to log a start and end pair + /// + public static IDisposable LogBlock(FunctionId functionId, CancellationToken token, LogLevel logLevel = LogLevel.Trace) + => LogBlock(functionId, string.Empty, token, logLevel); + + /// + /// simplest way to log a start and end pair with a simple context message which should be very cheap to create + /// + public static IDisposable LogBlock(FunctionId functionId, string? message, CancellationToken token, LogLevel logLevel = LogLevel.Trace) + => TryGetActiveEventSink(functionId, out var sink) + ? CreateLogBlock(sink, functionId, LogMessage.Create(message ?? "", logLevel), GetNextUniqueBlockId(), token) + : EmptyLogBlock.Instance; + + /// + /// log a start and end pair with a context message that will only be created when it is needed. + /// the messageGetter should be cheap to create. in another word, it shouldn't capture any locals + /// + public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, CancellationToken token, LogLevel logLevel = LogLevel.Trace) + => TryGetActiveEventSink(functionId, out var sink) + ? CreateLogBlock(sink, functionId, LogMessage.Create(messageGetter, logLevel), GetNextUniqueBlockId(), token) + : EmptyLogBlock.Instance; + + /// + /// log a start and end pair with a context message that requires some arguments to be created when requested. + /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals + /// + public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg arg, CancellationToken token, LogLevel logLevel = LogLevel.Trace) + => TryGetActiveEventSink(functionId, out var sink) + ? CreateLogBlock(sink, functionId, LogMessage.Create(messageGetter, arg, logLevel), GetNextUniqueBlockId(), token) + : EmptyLogBlock.Instance; + + /// + /// log a start and end pair with a context message that requires some arguments to be created when requested. + /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals + /// + public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, CancellationToken token, LogLevel logLevel = LogLevel.Trace) + => TryGetActiveEventSink(functionId, out var sink) + ? CreateLogBlock(sink, functionId, LogMessage.Create(messageGetter, arg0, arg1, logLevel), GetNextUniqueBlockId(), token) + : EmptyLogBlock.Instance; + + /// + /// log a start and end pair with a context message that requires some arguments to be created when requested. + /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals + /// + public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, CancellationToken token, LogLevel logLevel = LogLevel.Trace) + => TryGetActiveEventSink(functionId, out var sink) + ? CreateLogBlock(sink, functionId, LogMessage.Create(messageGetter, arg0, arg1, arg2, logLevel), GetNextUniqueBlockId(), token) + : EmptyLogBlock.Instance; + + /// + /// log a start and end pair with a context message that requires some arguments to be created when requested. + /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals + /// + public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, TArg3 arg3, CancellationToken token, LogLevel logLevel = LogLevel.Trace) + => TryGetActiveEventSink(functionId, out var sink) + ? CreateLogBlock(sink, functionId, LogMessage.Create(messageGetter, arg0, arg1, arg2, arg3, logLevel), GetNextUniqueBlockId(), token) + : EmptyLogBlock.Instance; + + /// + /// log a start and end pair with a context message. + /// + public static IDisposable LogBlock(FunctionId functionId, LogMessage logMessage, CancellationToken token) + => TryGetActiveEventSink(functionId, out var sink) + ? CreateLogBlock(sink, functionId, logMessage, GetNextUniqueBlockId(), token) + : EmptyLogBlock.Instance; +} diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs new file mode 100644 index 0000000000000..68eda4c2744a0 --- /dev/null +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; + +namespace Microsoft.CodeAnalysis.Internal.Log; + +/// +/// Maps onto the event and property names Roslyn's telemetry backend expects. +/// +/// This is deliberately the only place the vs/ide/vbcs/ naming convention appears. Sinks receive +/// already-final names, which is what lets a single sink implementation serve Roslyn (whose identity is +/// ) and Razor (whose identity is a plain string) without either knowing about +/// the other's naming. +/// +/// +internal static class TelemetryNaming +{ + public const string EventPrefix = "vs/ide/vbcs/"; + public const string PropertyPrefix = "vs.ide.vbcs."; + + // these don't have concurrency limit on purpose to reduce chance of lock contention. + // if that becomes a problem - by showing up in our perf investigation, then we will consider adding concurrency limit. + private static readonly ConcurrentDictionary s_eventMap = []; + private static readonly ConcurrentDictionary<(FunctionId id, string name), string> s_propertyMap = []; + + public static string GetEventName(FunctionId id) + => s_eventMap.GetOrAdd(id, id => EventPrefix + GetTelemetryName(id, separator: '/')); + + public static string GetPropertyName(FunctionId id, string name) + => s_propertyMap.GetOrAdd((id, name), key => PropertyPrefix + GetTelemetryName(key.id, separator: '.') + "." + key.name.ToLowerInvariant()); + + private static string GetTelemetryName(FunctionId id, char separator) + => Enum.GetName(typeof(FunctionId), id)!.Replace('_', separator).ToLowerInvariant(); +} diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs new file mode 100644 index 0000000000000..1a7d61db3db86 --- /dev/null +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.CodeAnalysis.Internal.Log; + +/// +/// Identifies the logical telemetry session a measurement belongs to. +/// +/// Today there is exactly one per process and this is a constant. It exists so that aggregation state +/// is never keyed on the assumption of a single session: a host that runs several independent language +/// servers in one process (daemon mode) needs each server's measurements bucketed - and posted - +/// separately, and retrofitting that into a single-session aggregation table would be a breaking change +/// to the aggregation implementation rather than a configuration change. +/// +/// +/// See for how a key is resolved at record time. +/// +/// +internal readonly struct TelemetrySessionKey : IEquatable +{ + /// + /// The key used when a host has not opted into per-session routing. + /// + public static readonly TelemetrySessionKey Default = new("default"); + + public string Id { get; } + + public TelemetrySessionKey(string id) + => Id = id; + + public bool Equals(TelemetrySessionKey other) + => string.Equals(Id, other.Id, StringComparison.Ordinal); + + public override bool Equals(object? obj) + => obj is TelemetrySessionKey other && Equals(other); + + public override int GetHashCode() + => Id?.GetHashCode() ?? 0; + + public override string ToString() + => Id ?? ""; +} From 6c3501a092203b849cb2c3599b667eb0b79853ff Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 16:22:32 -0700 Subject: [PATCH 02/34] Fix Razor VS Code metric data loss; delete the LSP host's ITelemetryReporter Razor's VS Code extension owns no TelemetrySession -- it posts through the language server host's session via ILanguageServerTelemetryReporterWrapper. Its ReportMetric override flattened the metric event to name + properties: _reporter.Report(metricEvent.Event.Name, metricEvent.Event.Properties) TelemetryMetricEvent pairs a TelemetryEvent (name + dimensions) with an IInstrument holding the aggregated values, and PostMetricEvent is the only thing that reads the instrument. AggregatingTelemetryLog puts exactly one property on its event -- "method" -- so every histogram Razor aggregated in VS Code arrived with a dimension and no measurements at all. The wrapper now forwards the TelemetryMetricEvent intact, so all three Razor hosts emit the same shape. Note this means VS Code starts sending aggregated Razor timings it never actually sent before. Consent still gates it through session.IsOptedIn. With the flattening gone, ITelemetryReporter (the LSP host's, not Razor's) had one member left with a real consumer and one implementation, so it is deleted. LanguageServerTelemetryReporter becomes a concrete export named LanguageServerTelemetryService, matching AbstractWorkspaceTelemetryService in the VS and ServiceHub hosts, and gains PostMetricEvent alongside Log for the Razor bridge. It could not have become an IEventSink: that contract is FunctionId-keyed, whereas this type's jobs are session lifecycle and posting arbitrary string-named events on another component's behalf. Validation: Ide.slnf, Compilers.slnf and Razor.slnf build clean; LSP telemetry tests 18/18 and Razor telemetry tests 19/19 pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .github/memory/known-issues/razor.md | 2 +- .github/memory/telemetry.md | 9 +++++- .../TelemetryReporterTests.cs | 11 ++++---- .../Contracts/ITelemetryReporter.cs | 18 ------------ .../Razor/TelemetryReporterWrapper.cs | 18 ++++++++---- .../Program.cs | 11 ++++---- ...r.cs => LanguageServerTelemetryService.cs} | 28 +++++++++++++++---- ...ILanguageServerTelemetryReporterWrapper.cs | 13 +++++++++ .../Services/TelemetryReporterWrapper.cs | 6 ++++ .../Services/VSCodeTelemetryReporter.cs | 10 +++++-- 10 files changed, 80 insertions(+), 46 deletions(-) delete mode 100644 src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Contracts/ITelemetryReporter.cs rename src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/{LanguageServerTelemetryReporter.cs => LanguageServerTelemetryService.cs} (83%) diff --git a/.github/memory/known-issues/razor.md b/.github/memory/known-issues/razor.md index 90c0528151441..a3f03635fa210 100644 --- a/.github/memory/known-issues/razor.md +++ b/.github/memory/known-issues/razor.md @@ -1,4 +1,4 @@ ---- +--- coverage: Razor-layer (src/Razor) known issues, quirks & workarounds --- diff --git a/.github/memory/telemetry.md b/.github/memory/telemetry.md index 155e03ae71c1b..00f125843d5e9 100644 --- a/.github/memory/telemetry.md +++ b/.github/memory/telemetry.md @@ -91,7 +91,7 @@ flush posts without standing up a real, opted-in `TelemetrySession`. |---|---|---| | **Visual Studio** | `VisualStudioWorkspaceTelemetryService.CreateLogger` via `AbstractWorkspaceTelemetryService.InitializeTelemetrySession` | `CodeMarkerLogger`, `EtwLogger`, `TraceLogger`, `RoslynActivityLogger.Sink`, `TelemetryLogger`, `FileLogger` + `VSMetricSink` | | **ServiceHub / OOP** | `RemoteWorkspaceTelemetryService.CreateLogger`; VS serializes its session and RPCs `InitializeTelemetrySessionAsync` | `EtwLogger`, `TraceLogger`, `TelemetryLogger` + `VSMetricSink` | -| **Standalone LSP** | `LanguageServerTelemetryReporter.InitializeSession`, called from `Program.cs` | `TelemetryLogger` + `VSMetricSink` | +| **Standalone LSP** | `LanguageServerTelemetryService.InitializeSession`, called from `Program.cs` | `TelemetryLogger` + `VSMetricSink` | | **VBCSCompiler** | `BuildServerController.RunServer` | none — uses `ICompilerServerLogger` only, by design | | **Tests** | `UseExportProviderAttribute` resets sinks after every test | none by default | @@ -114,3 +114,10 @@ is already tag-shaped (`Property` is a name/value pair and the overloads are `Re It still has its own aggregation implementation (`AggregatingTelemetryLog`, `AggregatingTelemetryLogManager`, and the request `Counter` inside `TelemetryReporter`), which duplicates `VSMetricSink`. Consolidating it is tracked separately — see `.github/memory/known-issues/razor.md`. + +Razor's VS Code extension owns no telemetry session; it posts through the language server host's session +via `ILanguageServerTelemetryReporterWrapper` (declared in Razor's assembly, implemented on the Roslyn +side by `TelemetryReporterWrapper`, because the dependency runs Roslyn → Razor). That wrapper forwards +`TelemetryMetricEvent`s **intact**. Flattening one to a name and property bag silently drops every +aggregated value, since those live on the event's instrument and are only read by +`TelemetrySession.PostMetricEvent` — this was a real defect in that path. diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs index 7276717420989..0f32baa3bbf21 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs @@ -5,7 +5,6 @@ using System.Reflection; using System.Runtime.Loader; using System.Text.Json.Nodes; -using Microsoft.CodeAnalysis.Contracts.Telemetry; using Microsoft.CodeAnalysis.LanguageServer.Telemetry; using Xunit.Abstractions; @@ -16,12 +15,12 @@ namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests; /// public sealed class TelemetryReporterTests(ITestOutputHelper testOutputHelper) : AbstractLanguageServerHostTests(testOutputHelper) { - private ITelemetryReporter CreateReporter(ServerConfiguration serverConfiguration) + private LanguageServerTelemetryService CreateReporter(ServerConfiguration serverConfiguration) { // VS Telemetry requires this environment variable to be set. Environment.SetEnvironmentVariable("CommonPropertyBagPath", Path.GetTempFileName()); - var reporter = (ITelemetryReporter?)Activator.CreateInstance(typeof(LanguageServerTelemetryReporter), serverConfiguration, LoggerFactory); + var reporter = (LanguageServerTelemetryService?)Activator.CreateInstance(typeof(LanguageServerTelemetryService), serverConfiguration, LoggerFactory); Assert.NotNull(reporter); return reporter; } @@ -60,7 +59,7 @@ public void TestLog() [InlineData(null, false)] public void TestCopilotCliTelemetryLevelFailsClosed(string? telemetryLevel, bool expected) { - Assert.Equal(expected, LanguageServerTelemetryReporter.IsCopilotCliTelemetryEnabled(telemetryLevel)); + Assert.Equal(expected, LanguageServerTelemetryService.IsCopilotCliTelemetryEnabled(telemetryLevel)); } [Fact] @@ -68,7 +67,7 @@ public void TestDevKitSessionPreservesVSCodeSettings() { using var currentProcess = System.Diagnostics.Process.GetCurrentProcess(); var processStartTime = currentProcess.StartTime.ToFileTimeUtc(); - var serializedSettings = LanguageServerTelemetryReporter.CreateDevKitSessionSettings("error", "test-session"); + var serializedSettings = LanguageServerTelemetryService.CreateDevKitSessionSettings("error", "test-session"); var expectedSettings = $$""" {"Id":"test-session","HostName":"Default","TelemetryLevel":"error","IsInitialSession":true,"CollectorApiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","AppId":1010,"ProcessStartTime":{{processStartTime}}} """; @@ -86,7 +85,7 @@ public void TestDevKitSessionPreservesVSCodeSettings() public void TestDevKitSessionPreservesTelemetryLevelValidation() { using var session = new Microsoft.VisualStudio.Telemetry.TelemetrySession( - LanguageServerTelemetryReporter.CreateDevKitSessionSettings("invalid", "test-session")); + LanguageServerTelemetryService.CreateDevKitSessionSettings("invalid", "test-session")); session.Start(); Assert.False(session.IsOptedIn); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Contracts/ITelemetryReporter.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Contracts/ITelemetryReporter.cs deleted file mode 100644 index 105a7de003f9e..0000000000000 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Contracts/ITelemetryReporter.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace Microsoft.CodeAnalysis.Contracts.Telemetry; - -internal interface ITelemetryReporter : IDisposable -{ - void InitializeSession(string telemetryLevel, string? sessionId, bool isDefaultSession); - - /// - /// Posts an already-named telemetry event with already-final property names. Used by the Razor - /// VS Code bridge (ILanguageServerTelemetryReporterWrapper), which owns no telemetry session - /// of its own and forwards through this reporter. Roslyn's own FunctionId-based - /// event pipeline does not go through here - it uses TelemetryLogger directly. - /// - void Log(string name, List> properties); -} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs index 81963e6b44b63..5707b3dc600d5 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs @@ -1,22 +1,28 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. using System.Composition; -using Microsoft.CodeAnalysis.Contracts.Telemetry; using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.LanguageServer.Telemetry; +using Microsoft.VisualStudio.Telemetry.Metrics.Events; using Microsoft.VisualStudioCode.RazorExtension.Services; namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.Razor; +/// +/// Supplies Razor's VS Code extension with access to this host's telemetry session, which it does not +/// own. The dependency runs Roslyn -> Razor, so Razor declares the contract and this implements it. +/// [Shared] [Export(typeof(ILanguageServerTelemetryReporterWrapper))] [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] -internal sealed class TelemetryReporterWrapper([Import(AllowDefault = true)] Lazy? telemetryReporter) : ILanguageServerTelemetryReporterWrapper +internal sealed class TelemetryReporterWrapper([Import(AllowDefault = true)] Lazy? telemetryService) : ILanguageServerTelemetryReporterWrapper { public void ReportEvent(string name, List> properties) - { - telemetryReporter?.Value.Log(name, properties); - } + => telemetryService?.Value.Log(name, properties); + + public void ReportMetric(TelemetryMetricEvent metricEvent) + => telemetryService?.Value.PostMetricEvent(metricEvent); } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs index 26115ea2fe499..7e5c73b87854f 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs @@ -8,7 +8,6 @@ using System.Reflection; using System.Runtime.InteropServices; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Contracts.Telemetry; using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.LanguageServer; using Microsoft.CodeAnalysis.LanguageServer.Logging; @@ -129,11 +128,11 @@ static async Task RunAsync(ServerConfiguration serverConfiguration, Cancell Directory.CreateDirectory(serverConfiguration.ExtensionLogDirectory); } - var telemetryLevel = LanguageServerTelemetryReporter.GetTelemetryLevel(serverConfiguration); - var telemetryReporter = telemetryLevel is not null - ? exportProvider.GetExportedValue() + var telemetryLevel = LanguageServerTelemetryService.GetTelemetryLevel(serverConfiguration); + var telemetryService = telemetryLevel is not null + ? exportProvider.GetExportedValue() : null; - telemetryReporter?.InitializeSession(telemetryLevel!, serverConfiguration.SessionId, isDefaultSession: true); + telemetryService?.InitializeSession(telemetryLevel!, serverConfiguration.SessionId, isDefaultSession: true); // Build the connection source for the configured mode. Single-server mode (stdio / connect-out pipe) yields // exactly one connection; daemon mode accepts many and manages its own idle timeout. Both run through the same @@ -200,7 +199,7 @@ serverConfiguration.ClientProcessId is int clientProcessId && finally { // After the LSP server shutdown, report session wide telemetry and dispose the session. - telemetryReporter?.Dispose(); + telemetryService?.Dispose(); } return ServerExitCodes.Success; diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryReporter.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs similarity index 83% rename from src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryReporter.cs rename to src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs index 08487df890e0b..ba52eef5b3895 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryReporter.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs @@ -6,18 +6,23 @@ using System.Diagnostics; using System.Text; using Microsoft.CodeAnalysis.Common; -using Microsoft.CodeAnalysis.Contracts.Telemetry; using Microsoft.CodeAnalysis.ErrorReporting; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Telemetry; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.Telemetry; +using Microsoft.VisualStudio.Telemetry.Metrics.Events; namespace Microsoft.CodeAnalysis.LanguageServer.Telemetry; -[Export(typeof(ITelemetryReporter)), Shared] -internal sealed class LanguageServerTelemetryReporter : ITelemetryReporter +/// +/// Owns the standalone language server host's telemetry session: creates and configures it, registers +/// the event and metric sinks, and tears everything down on shutdown. The counterpart to +/// AbstractWorkspaceTelemetryService in the VS and ServiceHub hosts. +/// +[Export, Shared] +internal sealed class LanguageServerTelemetryService : IDisposable { internal const string CopilotTelemetryLevelEnvironmentVariable = "COPILOT_TELEMETRY_LEVEL"; @@ -37,10 +42,10 @@ internal sealed class LanguageServerTelemetryReporter : ITelemetryReporter [ImportingConstructor] [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] - public LanguageServerTelemetryReporter(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory) + public LanguageServerTelemetryService(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory) { _serverConfiguration = serverConfiguration; - _logger = loggerFactory.CreateLogger(); + _logger = loggerFactory.CreateLogger(); } public void InitializeSession(string telemetryLevel, string? sessionId, bool isDefaultSession) @@ -97,6 +102,11 @@ internal static bool IsCopilotCliTelemetryEnabled(string? telemetryLevel) ? serverConfiguration.TelemetryLevel : Environment.GetEnvironmentVariable(CopilotTelemetryLevelEnvironmentVariable); + /// + /// Posts an already-named event with already-final property names, on behalf of a component that has + /// no session of its own (Razor's VS Code extension, via ILanguageServerTelemetryReporterWrapper). + /// Roslyn's own FunctionId-based events do not go through here. + /// public void Log(string name, List> properties) { if (_telemetrySession is null) @@ -109,6 +119,14 @@ public void Log(string name, List> properties) _telemetrySession.PostEvent(telemetryEvent); } + /// + /// Posts an aggregated measurement on behalf of a component that has no session of its own. The + /// event must arrive intact rather than flattened: the aggregated values live on its instrument and + /// are only read by . + /// + public void PostMetricEvent(TelemetryMetricEvent metricEvent) + => _telemetrySession?.PostMetricEvent(metricEvent); + public void Dispose() { // Ensure that telemetry aggregated over this session is reported and flushed *before* we diff --git a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs index 6389b5b1ebd42..12d199eb7f5d9 100644 --- a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs +++ b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs @@ -2,10 +2,23 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using Microsoft.VisualStudio.Telemetry.Metrics.Events; namespace Microsoft.VisualStudioCode.RazorExtension.Services; +/// +/// Lets Razor's VS Code extension post telemetry through the language server host's session, which it +/// does not own. Implemented on the Roslyn side; the dependency runs Roslyn -> Razor, so Razor declares +/// the contract and Roslyn supplies it. +/// internal interface ILanguageServerTelemetryReporterWrapper { void ReportEvent(string name, List> properties); + + /// + /// Posts an aggregated measurement. This must forward the intact + /// rather than flattening it to a name and property bag: the aggregated values live on the event's + /// instrument, and only TelemetrySession.PostMetricEvent reads them. + /// + void ReportMetric(TelemetryMetricEvent metricEvent); } diff --git a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/TelemetryReporterWrapper.cs b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/TelemetryReporterWrapper.cs index 166e576b4fd5e..3fc94cab9d01e 100644 --- a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/TelemetryReporterWrapper.cs +++ b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/TelemetryReporterWrapper.cs @@ -9,6 +9,7 @@ using Microsoft.CodeAnalysis.Razor.CohostingShared; using Microsoft.CodeAnalysis.LanguageServer; using Microsoft.CodeAnalysis.LanguageServer.Handler; +using Microsoft.VisualStudio.Telemetry.Metrics.Events; namespace Microsoft.VisualStudioCode.RazorExtension.Services; @@ -34,4 +35,9 @@ internal void Report(string name, IDictionary properties) { _telemetryReporterWrapper?.ReportEvent(name, properties.ToList()); } + + internal void ReportMetric(TelemetryMetricEvent metricEvent) + { + _telemetryReporterWrapper?.ReportMetric(metricEvent); + } } diff --git a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs index 4e8245bf6ed44..d102e8ac8c773 100644 --- a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs +++ b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs @@ -22,8 +22,9 @@ internal void SetTelemetryReporter(TelemetryReporterWrapper reporter) _reporter = reporter; } - // We override any method in the base class that does actual telemetry reporting, and redirect it - // through our wrapper, to the Roslyn reporter. + // This host has no telemetry session of its own - it posts through the language server host's + // session. We override the two methods that do the actual reporting and redirect them through our + // wrapper to the Roslyn reporter. protected override void Report(TelemetryEvent telemetryEvent) { @@ -32,6 +33,9 @@ protected override void Report(TelemetryEvent telemetryEvent) public override void ReportMetric(AggregatingTelemetryLog.TelemetryInstrumentEvent metricEvent) { - _reporter?.Report(metricEvent.Event.Name, metricEvent.Event.Properties); + // Forward the metric event intact. Flattening it to name + properties would drop the aggregated + // values entirely, because those live on the event's instrument and are only read by + // TelemetrySession.PostMetricEvent. + _reporter?.ReportMetric(metricEvent); } } From bd9da502afed7a2fa31cd22ffd65046804587a5e Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 16:36:17 -0700 Subject: [PATCH 03/34] Move the Razor telemetry bridge's translation into the bridge LanguageServerTelemetryService.Log and PostMetricEvent existed solely so TelemetryReporterWrapper could forward to them -- nothing in Roslyn's own telemetry used either. That put Razor-bridge translation on the host's telemetry service, one layer away from the only file that needs it. The service now exposes its Session and the wrapper does the posting itself. Ownership is unchanged: the wrapper only reads and posts, disposal stays with the service, so this does not reintroduce the double-dispose hazard that ruled out handing Razor the session via TelemetryReporter.SetSession. LanguageServerTelemetryService is left with session lifecycle only: InitializeSession, Dispose, and the three configuration statics. TestLog is retargeted at the bridge as TestRazorBridgePostsThroughTheHostSession and now covers both directions, including the metric path that previously had no coverage at all. Validation: Ide.slnf, Compilers.slnf and Razor.slnf build clean; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../TelemetryReporterTests.cs | 24 +++++++++++-- .../Razor/TelemetryReporterWrapper.cs | 29 +++++++++++++--- .../LanguageServerTelemetryService.cs | 34 +++---------------- 3 files changed, 51 insertions(+), 36 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs index 0f32baa3bbf21..0f01437610491 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs @@ -5,7 +5,11 @@ using System.Reflection; using System.Runtime.Loader; using System.Text.Json.Nodes; +using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.Razor; using Microsoft.CodeAnalysis.LanguageServer.Telemetry; +using Microsoft.VisualStudio.Telemetry; +using Microsoft.VisualStudio.Telemetry.Metrics; +using Microsoft.VisualStudio.Telemetry.Metrics.Events; using Xunit.Abstractions; namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests; @@ -38,12 +42,28 @@ public void TestVSTelemetryLoadedIntoDefaultAlc() Assert.Contains(AssemblyLoadContext.Default.Assemblies, a => a.GetName().Name == "Microsoft.VisualStudio.Telemetry"); } + /// + /// Razor's VS Code extension owns no telemetry session and posts through this host's, via + /// . Covers both directions of that bridge. + /// [Fact] - public void TestLog() + public void TestRazorBridgePostsThroughTheHostSession() { using var service = CreateReporter(DefaultServerConfiguration); service.InitializeSession("off", "test-session", isDefaultSession: false); - service.Log(GetEventName(nameof(TestLog)), []); + + // Constructed the same way as CreateReporter above: the MEF importing constructor is marked + // obsolete-as-error, so tests go through Activator rather than calling it directly. + var wrapper = (TelemetryReporterWrapper?)Activator.CreateInstance( + typeof(TelemetryReporterWrapper), new Lazy(() => service)); + Assert.NotNull(wrapper); + + wrapper.ReportEvent(GetEventName(nameof(TestRazorBridgePostsThroughTheHostSession)), [new("method", "textDocument/hover")]); + + var meter = new VSTelemetryMeterProvider().CreateMeter("test.meter"); + var histogram = meter.CreateHistogram("Duration"); + histogram.Record(42); + wrapper.ReportMetric(new TelemetryHistogramEvent(new TelemetryEvent(GetEventName("metric")), histogram)); } [Theory] diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs index 5707b3dc600d5..d2836ebb450fd 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs @@ -5,14 +5,21 @@ using System.Composition; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.LanguageServer.Telemetry; +using Microsoft.VisualStudio.Telemetry; using Microsoft.VisualStudio.Telemetry.Metrics.Events; using Microsoft.VisualStudioCode.RazorExtension.Services; namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.Razor; /// -/// Supplies Razor's VS Code extension with access to this host's telemetry session, which it does not -/// own. The dependency runs Roslyn -> Razor, so Razor declares the contract and this implements it. +/// Lets Razor's VS Code extension post telemetry through this host's session, which it does not own. +/// The dependency runs Roslyn -> Razor, so Razor declares the contract and this implements it. +/// +/// Razor's names and properties are already final when they arrive - they do not go through Roslyn's +/// FunctionId pipeline - so this posts to the session directly rather than through +/// RoslynTelemetry. It only reads the session; ownership and disposal stay with +/// . +/// /// [Shared] [Export(typeof(ILanguageServerTelemetryReporterWrapper))] @@ -21,8 +28,22 @@ namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.Razor; internal sealed class TelemetryReporterWrapper([Import(AllowDefault = true)] Lazy? telemetryService) : ILanguageServerTelemetryReporterWrapper { public void ReportEvent(string name, List> properties) - => telemetryService?.Value.Log(name, properties); + { + if (telemetryService?.Value.Session is not { } session) + return; + var telemetryEvent = new TelemetryEvent(name); + foreach (var property in properties) + telemetryEvent.Properties.Add(property); + + session.PostEvent(telemetryEvent); + } + + /// + /// Posts an aggregated measurement. The event must arrive intact rather than flattened to a name and + /// property bag: the aggregated values live on its instrument and are only read by + /// . + /// public void ReportMetric(TelemetryMetricEvent metricEvent) - => telemetryService?.Value.PostMetricEvent(metricEvent); + => telemetryService?.Value.Session?.PostMetricEvent(metricEvent); } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs index ba52eef5b3895..cd48fd165fa7b 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs @@ -103,29 +103,11 @@ internal static bool IsCopilotCliTelemetryEnabled(string? telemetryLevel) : Environment.GetEnvironmentVariable(CopilotTelemetryLevelEnvironmentVariable); /// - /// Posts an already-named event with already-final property names, on behalf of a component that has - /// no session of its own (Razor's VS Code extension, via ILanguageServerTelemetryReporterWrapper). - /// Roslyn's own FunctionId-based events do not go through here. + /// The active session, for the one component that needs to post through it directly: Razor's VS Code + /// extension, which owns no session of its own. Roslyn's own telemetry never uses this - it goes + /// through and the registered sinks. /// - public void Log(string name, List> properties) - { - if (_telemetrySession is null) - { - return; - } - - var telemetryEvent = new TelemetryEvent(name); - SetProperties(telemetryEvent, properties); - _telemetrySession.PostEvent(telemetryEvent); - } - - /// - /// Posts an aggregated measurement on behalf of a component that has no session of its own. The - /// event must arrive intact rather than flattened: the aggregated values live on its instrument and - /// are only read by . - /// - public void PostMetricEvent(TelemetryMetricEvent metricEvent) - => _telemetrySession?.PostMetricEvent(metricEvent); + internal TelemetrySession? Session => _telemetrySession; public void Dispose() { @@ -192,12 +174,4 @@ static string StringToJsonValue(string? value) return '"' + value + '"'; } } - - private static void SetProperties(TelemetryEvent telemetryEvent, List> properties) - { - foreach (var property in properties) - { - telemetryEvent.Properties.Add(property); - } - } } From 16585d52d5217fed4b1666b2e1d1a11059b36974 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 16:52:32 -0700 Subject: [PATCH 04/34] Rewrite added comments to describe current behavior Comments explaining what a type or method replaced, or narrating the change that introduced it, are only meaningful against a diff. Replaced with what the code does and why, for a reader starting from a clean checkout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../TelemetryReporterTests.cs | 3 +- .../VSMetricSinkTests.cs | 15 ++--- .../Razor/TelemetryReporterWrapper.cs | 11 ++-- .../LanguageServerTelemetryService.cs | 12 ++-- ...ILanguageServerTelemetryReporterWrapper.cs | 6 +- .../Services/VSCodeTelemetryReporter.cs | 10 ++-- .../Core/Def/RoslynActivityLogger.cs | 6 +- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 56 +++++++++---------- .../VisualStudioWorkspaceTelemetryService.cs | 7 +-- .../OptionPages/PerformanceLoggersPage.cs | 4 +- src/Workspaces/Core/Portable/Log/EtwLogger.cs | 13 ++--- .../Log/RoslynTelemetry.Workspaces.cs | 2 +- .../Core/Portable/Log/TraceLogger.cs | 3 +- .../RemoteProcessTelemetryService.cs | 2 +- .../RemoteWorkspaceTelemetryService.cs | 4 +- .../Compiler/Core/Log/AggregateEventSink.cs | 5 +- .../Compiler/Core/Log/IMetricSink.cs | 13 ++--- .../Compiler/Core/Log/Logger.cs | 12 ++-- .../Core/Log/RoslynTelemetry.Metrics.cs | 15 +++-- .../Compiler/Core/Log/RoslynTelemetry.cs | 8 +-- .../Compiler/Core/Log/TelemetryNaming.cs | 7 +-- .../Compiler/Core/Log/TelemetrySessionKey.cs | 8 +-- 22 files changed, 101 insertions(+), 121 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs index 0f01437610491..c228826fd12b5 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs @@ -52,8 +52,7 @@ public void TestRazorBridgePostsThroughTheHostSession() using var service = CreateReporter(DefaultServerConfiguration); service.InitializeSession("off", "test-session", isDefaultSession: false); - // Constructed the same way as CreateReporter above: the MEF importing constructor is marked - // obsolete-as-error, so tests go through Activator rather than calling it directly. + // The MEF importing constructor is obsolete-as-error, so construct through Activator. var wrapper = (TelemetryReporterWrapper?)Activator.CreateInstance( typeof(TelemetryReporterWrapper), new Lazy(() => service)); Assert.NotNull(wrapper); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs index dc9a4991cb98a..b5c361280551e 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs @@ -13,9 +13,8 @@ namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests; /// -/// Guards the aggregation invariants the previous implementation learned the hard way: every recorded -/// measurement must be posted exactly once per flush - never dropped, never double-counted - and -/// measurements must land in the right bucket. +/// Covers the aggregation invariants: every recorded measurement is posted exactly once per flush - +/// never dropped, never double-counted - and measurements land in the right bucket. /// public sealed class VSMetricSinkTests { @@ -28,7 +27,6 @@ private sealed class RecordingPoster : VSMetricSink.IMetricPoster /// TelemetryMetricEvent does not expose them. /// public List PostedEvents { get; } = []; - public bool IsOptedIn { get; set; } = true; public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent) @@ -50,10 +48,10 @@ public void RecordedMeasurementsArePostedExactlyOncePerFlush() sink.Flush(); - // Two distinct instruments -> exactly two events. Never zero (dropped), never more (double-counted). + // Two distinct instruments -> exactly two events. Assert.Equal(2, poster.Posted.Count); - // Flush also clears, so a second flush must not re-post anything. + // Flush clears, so a second flush posts nothing. poster.Posted.Clear(); sink.Flush(); Assert.Empty(poster.Posted); @@ -65,8 +63,7 @@ public void TagValuesDiscriminateBuckets() var poster = new RecordingPoster(); var sink = new VSMetricSink(poster); - // Same event and metric, different tag values. These are the dimensions call sites used to - // concatenate by hand into a single compound bucket key. + // Same event and metric, different tag values: these must aggregate into separate buckets. sink.Record("vs/ide/vbcs/lsp/requestduration", "RequestDuration", 10, new KeyValuePair[] { new("server", "Roslyn"), new("method", "textDocument/hover") }); sink.Record("vs/ide/vbcs/lsp/requestduration", "RequestDuration", 20, @@ -80,7 +77,7 @@ public void TagValuesDiscriminateBuckets() } [Fact] - public void EventAndPropertyNamesMatchThePreviousShape() + public void EventAndPropertyNamesUseTheTelemetryConvention() { var poster = new RecordingPoster(); var sink = new VSMetricSink(poster); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs index d2836ebb450fd..17a9b0ba70e2e 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs @@ -16,9 +16,8 @@ namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.Razor; /// The dependency runs Roslyn -> Razor, so Razor declares the contract and this implements it. /// /// Razor's names and properties are already final when they arrive - they do not go through Roslyn's -/// FunctionId pipeline - so this posts to the session directly rather than through -/// RoslynTelemetry. It only reads the session; ownership and disposal stay with -/// . +/// FunctionId pipeline - so this posts to the session directly. It only reads the session; +/// ownership and disposal stay with . /// /// [Shared] @@ -40,9 +39,9 @@ public void ReportEvent(string name, List> propert } /// - /// Posts an aggregated measurement. The event must arrive intact rather than flattened to a name and - /// property bag: the aggregated values live on its instrument and are only read by - /// . + /// Posts an aggregated measurement. The event must arrive intact: its aggregated values live on its + /// instrument, and only reads them. Flattening it to + /// a name and property bag would discard every measurement. /// public void ReportMetric(TelemetryMetricEvent metricEvent) => telemetryService?.Value.Session?.PostMetricEvent(metricEvent); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs index cd48fd165fa7b..f4a2ab5f5ded6 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs @@ -20,8 +20,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Telemetry; /// Owns the standalone language server host's telemetry session: creates and configures it, registers /// the event and metric sinks, and tears everything down on shutdown. The counterpart to /// AbstractWorkspaceTelemetryService in the VS and ServiceHub hosts. -/// -[Export, Shared] +/// [Export, Shared] internal sealed class LanguageServerTelemetryService : IDisposable { internal const string CopilotTelemetryLevelEnvironmentVariable = "COPILOT_TELEMETRY_LEVEL"; @@ -84,9 +83,6 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD _telemetrySession = session; - // Register the shared FunctionId-based event sink. Previously this instance was created and - // then discarded, with RoslynLogger -- an independent, byte-for-byte reimplementation of the - // same logic -- registered instead. RoslynTelemetry.SetEventSink(AggregateEventSink.Create(RoslynTelemetry.GetEventSink(), TelemetryLogger.Create(session, logDelta: false))); FaultReporter.InitializeFatalErrorHandlers(); @@ -103,9 +99,9 @@ internal static bool IsCopilotCliTelemetryEnabled(string? telemetryLevel) : Environment.GetEnvironmentVariable(CopilotTelemetryLevelEnvironmentVariable); /// - /// The active session, for the one component that needs to post through it directly: Razor's VS Code - /// extension, which owns no session of its own. Roslyn's own telemetry never uses this - it goes - /// through and the registered sinks. + /// The active session, for the one component that posts through it directly: Razor's VS Code + /// extension, which owns no session of its own. Roslyn's own telemetry goes through + /// and the registered sinks. /// internal TelemetrySession? Session => _telemetrySession; diff --git a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs index 12d199eb7f5d9..038778cdb3fb7 100644 --- a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs +++ b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs @@ -16,9 +16,9 @@ internal interface ILanguageServerTelemetryReporterWrapper void ReportEvent(string name, List> properties); /// - /// Posts an aggregated measurement. This must forward the intact - /// rather than flattening it to a name and property bag: the aggregated values live on the event's - /// instrument, and only TelemetrySession.PostMetricEvent reads them. + /// Posts an aggregated measurement. The event must be forwarded intact: its aggregated values live + /// on its instrument, and only TelemetrySession.PostMetricEvent reads them. Flattening it to + /// a name and property bag would discard every measurement. /// void ReportMetric(TelemetryMetricEvent metricEvent); } diff --git a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs index d102e8ac8c773..767579f2536a3 100644 --- a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs +++ b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs @@ -22,8 +22,8 @@ internal void SetTelemetryReporter(TelemetryReporterWrapper reporter) _reporter = reporter; } - // This host has no telemetry session of its own - it posts through the language server host's - // session. We override the two methods that do the actual reporting and redirect them through our + // This host has no telemetry session of its own; it posts through the language server host's + // session. Override the two methods that do the actual reporting and redirect them through the // wrapper to the Roslyn reporter. protected override void Report(TelemetryEvent telemetryEvent) @@ -33,9 +33,9 @@ protected override void Report(TelemetryEvent telemetryEvent) public override void ReportMetric(AggregatingTelemetryLog.TelemetryInstrumentEvent metricEvent) { - // Forward the metric event intact. Flattening it to name + properties would drop the aggregated - // values entirely, because those live on the event's instrument and are only read by - // TelemetrySession.PostMetricEvent. + // Forwarded intact: the aggregated values live on the event's instrument, and only + // TelemetrySession.PostMetricEvent reads them. Flattening to name + properties would discard + // every measurement. _reporter?.ReportMetric(metricEvent); } } diff --git a/src/VisualStudio/Core/Def/RoslynActivityLogger.cs b/src/VisualStudio/Core/Def/RoslynActivityLogger.cs index d975bd7321df0..36ddbfee4a9a0 100644 --- a/src/VisualStudio/Core/Def/RoslynActivityLogger.cs +++ b/src/VisualStudio/Core/Def/RoslynActivityLogger.cs @@ -22,9 +22,9 @@ namespace Microsoft.VisualStudio.LanguageServices; internal static class RoslynActivityLogger { /// - /// A single sink, composed once at startup, whose contents vary rather than its - /// registration. Adding and removing a mutates this set; when the set is - /// empty the sink reports itself disabled and costs one array-length check per event. + /// A single sink, composed once at startup, whose contents vary. Adding and removing a + /// mutates this set; when the set is empty the sink reports itself + /// disabled and costs one array-length check per event. /// public static readonly TraceSourceSink Sink = new(); diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index 2b1bc88e558dd..63c464a9d2529 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -16,32 +16,31 @@ namespace Microsoft.CodeAnalysis.Telemetry; /// -/// The single aggregating metric implementation, backed by VS Telemetry's counter/histogram APIs. +/// The aggregating metric sink, backed by VS Telemetry's counter and histogram APIs. /// -/// Replaces four previously separate wrappers around the same VS Telemetry surface -/// (AbstractAggregatingLog, AggregatingCounterLog, AggregatingHistogramLog, and -/// TelemetryLogProvider) as well as Razor's independent copy. +/// Measurements accumulate in memory against a VS Telemetry instrument and are posted in batches by +/// . Aggregation is keyed by in addition to the +/// instrument identity, so a process hosting more than one logical session accumulates - and posts - +/// each session's data separately. /// /// -/// Aggregation is keyed by in addition to the instrument identity, so -/// a process hosting more than one logical session accumulates - and posts - each session's data -/// separately. is deliberately global: it walks every bucket, posts each to the -/// session that produced it, and clears. Clearing on flush is also what keeps a long-lived process from -/// accruing buckets for sessions that have ended. +/// is global: it walks every bucket, posts each to the session that produced it, and +/// clears. Clearing on flush is what keeps a long-lived process from accruing buckets for sessions that +/// have ended. /// /// internal sealed class VSMetricSink : IMetricSink { /// - /// Indicates version information which vs telemetry will use for our aggregated telemetry. This can be used - /// by Kusto queries to filter against telemetry versions which have the specified version and thus desired shape. + /// Version information which VS Telemetry attaches to our aggregated telemetry, so that Kusto + /// queries can filter to the versions whose shape they understand. /// private const string MeterVersion = "0.40"; /// - /// The per-session capability this sink actually needs. Exists so that tests can assert exactly how - /// many metric events a flush posts without standing up a real, opted-in - /// (which would try to send). + /// The per-session capability this sink needs. Abstracted so that tests can assert exactly how many + /// metric events a flush posts without standing up a real, opted-in + /// (which would try to send). /// internal interface IMetricPoster { @@ -64,9 +63,10 @@ private sealed class Aggregation(IInstrument instrument, TelemetryEvent telemetr public IMetricPoster Poster { get; } = poster; /// - /// Guards this single aggregation. Paired with exactly as the - /// previous implementation did - see https://github.com/dotnet/roslyn/pull/71606, which added this - /// two-level locking because concurrent PostMetricEvent calls for one instrument were crashing. + /// Guards this single aggregation. Held together with : + /// concurrent PostMetricEvent calls for one instrument crash the VS Telemetry SDK, so a + /// flush must exclude both other flushes and any in-flight Add/Record on the same instrument. + /// See https://github.com/dotnet/roslyn/pull/71606. /// public object Lock { get; } = new(); } @@ -132,8 +132,7 @@ public void Record(string eventName, string metricName, long value, ReadOnlySpan public void Flush() { - // This lock ensures that multiple calls to Flush cannot occur simultaneously. Without it we could - // call PostMetricEvent multiple times for the same aggregation. + // Excludes other flushes, which would otherwise post the same aggregation twice. lock (_flushLock) { var aggregations = Interlocked.Exchange(ref _aggregations, ImmutableDictionary.Empty); @@ -144,8 +143,8 @@ public void Flush() if (!aggregation.Poster.IsOptedIn) continue; - // This fine-grained lock ensures the aggregation isn't modified (via an Add/Record call) - // during the creation of the TelemetryMetricEvent or the PostMetricEvent call on it. + // Excludes concurrent Add/Record on this instrument while the metric event is built + // from it and posted. lock (aggregation.Lock) { TelemetryMetricEvent metricEvent = aggregation.Instrument switch @@ -167,8 +166,7 @@ public void Flush() if (!_posters.TryGetValue(sessionKey, out var poster)) poster = _defaultPoster; - // Consent is checked here rather than at the call site so that no telemetry object graph is built - // for an opted-out session -- the source of a large amount of throwaway allocation historically. + // Checked here so that no telemetry object graph is built for an opted-out session. if (!poster.IsOptedIn) return null; @@ -207,23 +205,21 @@ private IMeter GetOrCreateMeter(string eventName) _meterProvider); /// - /// Reproduces the meter name the previous per-FunctionId implementation produced - /// (vs.ide.vbcs.some.operation.meter) from the already-derived event name - /// (vs/ide/vbcs/some/operation), so emitted telemetry keeps its existing shape. + /// Derives the meter name (vs.ide.vbcs.some.operation.meter) from the event name + /// (vs/ide/vbcs/some/operation). /// private static string GetMeterName(string eventName) => eventName.Replace('/', '.') + ".meter"; /// - /// Reproduces the previous property naming (vs.ide.vbcs.some.operation.tagname). + /// Derives a property name (vs.ide.vbcs.some.operation.tagname) from the event name. /// private static string GetPropertyName(string eventName, string tagName) => eventName.Replace('/', '.') + "." + tagName.ToLowerInvariant(); /// - /// Builds the bucket discriminator from the tag values, in declaration order. This reproduces the - /// compound name the previous call sites concatenated by hand (for example - /// "server.method.language") so that measurements aggregate exactly as they did before. + /// Builds the bucket discriminator from the tag values, in declaration order, so that measurements + /// differing in any dimension aggregate separately. /// private static string BuildDimensionKey(ReadOnlySpan> tags) { diff --git a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs index 2de6a0c0b77c2..431d60ac65199 100644 --- a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs @@ -33,8 +33,8 @@ internal sealed class VisualStudioWorkspaceTelemetryService( /// /// Opt-in diagnostic sinks. Composed once, at startup, and thereafter enabled or disabled through - /// their own predicates by the Performance Loggers options page - never added to or removed from the - /// sink list, which is what guarantees each is registered exactly once. + /// their own predicates by the Performance Loggers options page. Keeping them in the composed list + /// is what guarantees each is registered exactly once. /// private EtwLogger? _etwLogger; private TraceLogger? _traceLogger; @@ -55,8 +55,7 @@ protected override IEventSink CreateLogger(TelemetrySession telemetrySession, bo } /// - /// Refreshes the enablement of the composed opt-in sinks. Called by the Performance Loggers options - /// page; deliberately updates the existing instances rather than constructing new ones. + /// Refreshes the enablement of the composed opt-in sinks, for the Performance Loggers options page. /// internal void UpdateDiagnosticSinkEnablement(bool etwEnabled, bool traceEnabled, Func isEnabled) { diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs index 32af957f0177a..d02ab7ad8c31d 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs @@ -62,8 +62,8 @@ public static void SetLoggers(IGlobalOptionService globalOptions, IThreadingCont var traceEnabled = globalOptions.GetOption(LoggerOptionsStorage.TraceLoggerKey); var outputWindowEnabled = globalOptions.GetOption(LoggerOptionsStorage.OutputWindowLoggerKey); - // ETW and Trace sinks are part of VS's default composition, so refresh those instances rather - // than constructing competing ones - two registered EtwLoggers would post every event twice. + // ETW and Trace sinks are part of VS's default composition, so refresh those instances. Two + // registered EtwLoggers would post every event twice. var telemetryService = workspaceServices.GetService() as VisualStudioWorkspaceTelemetryService; telemetryService?.UpdateDiagnosticSinkEnablement(etwEnabled, traceEnabled, isEnabled); diff --git a/src/Workspaces/Core/Portable/Log/EtwLogger.cs b/src/Workspaces/Core/Portable/Log/EtwLogger.cs index 9dcbd7178da85..9fa3d5a2fa721 100644 --- a/src/Workspaces/Core/Portable/Log/EtwLogger.cs +++ b/src/Workspaces/Core/Portable/Log/EtwLogger.cs @@ -10,15 +10,14 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// /// A sink that publishes events to ETW using an EventSource. Opt-in: enabled per- -/// by a predicate that the host can swap at runtime (Tools -> Options -> Performance Loggers). It stays -/// registered for the lifetime of the process; "disabled" means the predicate rejects everything, which -/// is what keeps a second instance from ever being composed alongside this one and double-posting. +/// by a predicate the host can swap at runtime (Tools -> Options -> Performance Loggers). It stays +/// registered for the lifetime of the process; "disabled" means the predicate rejects everything. /// internal sealed class EtwLogger : IEventSink { /// - /// A predicate that rejects every . Used as the initial state for sinks - /// that are off until a user turns them on. + /// A predicate that rejects every . The initial state for sinks that are off + /// until a user turns them on. /// public static readonly Func DisabledPredicate = static _ => false; @@ -32,8 +31,8 @@ public EtwLogger(Func isEnabledPredicate) => _isEnabledPredicate = isEnabledPredicate; /// - /// Replaces the enablement predicate in place. Callers must refresh the composed instance rather - /// than constructing a competing one, or events would be posted twice. + /// Replaces the enablement predicate in place. Callers must refresh the composed instance; composing + /// a second one alongside it would post every event twice. /// public void UpdatePredicate(Func isEnabledPredicate) => Volatile.Write(ref _isEnabledPredicate, isEnabledPredicate); diff --git a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs index f5641c2ff1aa1..18d67c787ff79 100644 --- a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs +++ b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs @@ -20,7 +20,7 @@ internal static class TelemetryKeys /// /// The parts of that need , which lives in -/// the Workspaces layer rather than the dependency-minimal shared layer. +/// the Workspaces layer and so is not available in the shared layer. /// internal static partial class RoslynTelemetry { diff --git a/src/Workspaces/Core/Portable/Log/TraceLogger.cs b/src/Workspaces/Core/Portable/Log/TraceLogger.cs index 5b2b761509241..77359ce3a765d 100644 --- a/src/Workspaces/Core/Portable/Log/TraceLogger.cs +++ b/src/Workspaces/Core/Portable/Log/TraceLogger.cs @@ -13,8 +13,7 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// the same way as : it stays registered and its predicate decides whether /// anything is written. /// -internal sealed class TraceLogger : IEventSink -{ +internal sealed class TraceLogger : IEventSink{ private Func _isEnabledPredicate; public TraceLogger(Func isEnabledPredicate) diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs index 5a97ef449fbc7..cb94eaa138d59 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs @@ -80,7 +80,7 @@ public ValueTask EnableLoggingAsync(ImmutableArray loggerTypeNames, Immu var functionIdsSet = new HashSet(functionIds); bool logChecker(FunctionId id) => functionIdsSet.Contains(id); - // Mirrors the VS side: the sinks are composed once and only their enablement changes here. + // The sinks are composed once at startup; only their enablement changes here. var telemetryService = (RemoteWorkspaceTelemetryService)GetWorkspace().Services.GetRequiredService(); telemetryService.UpdateDiagnosticSinkEnablement( etwEnabled: loggerTypeNames.Contains(nameof(EtwLogger)), diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs index 0c142a4276a12..c94fc0729dda6 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs @@ -17,8 +17,8 @@ namespace Microsoft.VisualStudio.LanguageServices.Telemetry; internal sealed class RemoteWorkspaceTelemetryService() : AbstractWorkspaceTelemetryService { /// - /// Opt-in diagnostic sinks, mirroring the VS host. Composed once and thereafter toggled through - /// their predicates by IRemoteProcessTelemetryService.EnableLoggingAsync. + /// Opt-in diagnostic sinks. Composed once, at startup, and thereafter toggled through their + /// predicates by IRemoteProcessTelemetryService.EnableLoggingAsync. /// private EtwLogger? _etwLogger; private TraceLogger? _traceLogger; diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs index 923b05d205cb3..a0a49790ee77b 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs @@ -11,9 +11,8 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// /// Fans an event out to a fixed set of sinks. The set is decided once, when a host composes its /// telemetry, and is not mutated afterwards: turning a sink off is that sink's own -/// returning false, not its removal from this list. That keeps a -/// sink from being registered twice (which would post its events twice) and removes the need for the -/// predicate-based add/replace/remove that used to live here. +/// returning false. Keeping the set fixed is what guarantees a sink +/// cannot be registered twice, which would post its events twice. /// internal sealed class AggregateEventSink : IEventSink { diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs index 0b249dbc6eb75..b7f7f22972763 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs @@ -11,15 +11,14 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// A destination for aggregated measurements. Implementations accumulate values in memory and post /// them in batches when is called. /// -/// The contract is deliberately free of any telemetry-backend or BCL-metrics types so that it can live -/// in the dependency-minimal shared layer, and deliberately keyed by a plain -/// string rather than so that Razor - which has no - -/// can share the same implementation. Roslyn's -to-event-name mapping happens -/// one level up, in . +/// The contract names no telemetry-backend or BCL-metrics type, so it can live in the +/// dependency-minimal shared layer. It is keyed by a plain eventName string so that one +/// implementation can serve both Roslyn and Razor; Roslyn's -to-event-name +/// mapping happens one level up, in . /// /// -/// The tag parameter mirrors System.Diagnostics.Metrics.Counter<T>.Add exactly, so that -/// recording can later be moved onto BCL metric instruments without touching any call site. +/// The tag parameter mirrors System.Diagnostics.Metrics.Counter<T>.Add, so recording could +/// move onto BCL metric instruments without touching any call site. /// /// internal interface IMetricSink diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs index 5f57df67b42ed..1ba499378679a 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs @@ -8,14 +8,12 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// Temporary forwarding shim onto , kept so that this rename did not have to -/// touch the 150+ existing Logger.Log / Logger.LogBlock call sites in one change. New -/// code should call directly. +/// Forwarding shim onto . New code should call +/// directly; this exists only to serve existing call sites and is +/// intended to be deleted once they have been updated. /// -/// This type is intended to be deleted once its call sites have been mechanically updated; see -/// https://github.com/dotnet/roslyn/issues/ for the tracking issue. It deliberately carries no -/// because the repository builds with warnings as errors and the -/// remaining call sites are expected, not accidental. +/// It carries no because the repository builds with warnings as errors +/// and its remaining call sites are expected, not accidental. /// /// internal static class Logger diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs index b9f181d597e15..a18e9dc09e27e 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -24,10 +24,9 @@ internal static partial class RoslynTelemetry /// /// The session that measurements recorded on this thread belong to. /// - /// Ambient routing is not enabled today, so this is always . - /// The seam exists so that implementations bucket by session from the - /// start; enabling it later is a matter of setting and pushing - /// keys around the work that belongs to each session, with no change to any call site or to any sink. + /// Ambient routing is not enabled, so this is always . + /// Enabling it is a matter of setting and pushing keys around + /// the work belonging to each session; no call site or sink needs to change. /// /// internal static TelemetrySessionKey CurrentSessionKey @@ -45,8 +44,8 @@ internal static TelemetrySessionKey CurrentSessionKey /// /// Posts all pending aggregated measurements. Called on a timer, at shutdown, and when a logical - /// session ends. Every session's accumulated data is posted to its own session; it is safe (and - /// intentional) for this to flush more than the caller's own session. + /// session ends. Every session's accumulated data is posted to its own session, so flushing more + /// than the caller's own session is both safe and intended. /// public static void Flush() => s_currentMetricSink?.Flush(); @@ -86,6 +85,10 @@ public static void Count(FunctionId functionId, string metricName, long delta, K } } + /// + /// Span-based entry point. Kept private because it is ambiguous with the single-tag overload at call + /// sites that use target-typed new(...). + /// private static void CountCore(FunctionId functionId, string metricName, long delta, ReadOnlySpan> tags) { if (s_currentMetricSink is { } sink) diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index e48393df25628..ba80a607968b3 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -13,8 +13,8 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// host's configured ; aggregated measurements go to its . /// /// A host configures this once at startup (see / ). -/// With nothing configured every method is a cheap no-op, which is what the build server, the CodeStyle -/// packages, and most tests rely on. +/// With nothing configured every method is a cheap no-op, which is the state the build server, the +/// CodeStyle packages, and most tests run in. /// /// internal static partial class RoslynTelemetry @@ -42,8 +42,8 @@ internal static partial class RoslynTelemetry /// /// Atomically adds alongside whatever is already registered. Used by /// diagnostic sinks that live in assemblies the composition root cannot reference (the diagnostics - /// tool window, integration tests), which attach once and are thereafter controlled by their own - /// rather than by being detached. + /// tool window, integration tests). Such a sink attaches once and is thereafter controlled by its + /// own ; it is never detached. /// public static void AddEventSink(IEventSink sink) { diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs index 68eda4c2744a0..c45d77de8e4b9 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs @@ -10,10 +10,9 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// /// Maps onto the event and property names Roslyn's telemetry backend expects. /// -/// This is deliberately the only place the vs/ide/vbcs/ naming convention appears. Sinks receive -/// already-final names, which is what lets a single sink implementation serve Roslyn (whose identity is -/// ) and Razor (whose identity is a plain string) without either knowing about -/// the other's naming. +/// This is the only place the vs/ide/vbcs/ naming convention appears. Sinks receive already-final +/// names, so one sink implementation can serve both Roslyn, whose identity is , +/// and Razor, whose identity is a plain string. /// /// internal static class TelemetryNaming diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs index 1a7d61db3db86..cc6293565d2a8 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs @@ -9,11 +9,9 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// /// Identifies the logical telemetry session a measurement belongs to. /// -/// Today there is exactly one per process and this is a constant. It exists so that aggregation state -/// is never keyed on the assumption of a single session: a host that runs several independent language -/// servers in one process (daemon mode) needs each server's measurements bucketed - and posted - -/// separately, and retrofitting that into a single-session aggregation table would be a breaking change -/// to the aggregation implementation rather than a configuration change. +/// A host that runs several independent language servers in one process needs each server's +/// measurements bucketed - and posted - separately, so aggregation state is keyed by this. Today there +/// is exactly one session per process and this is always . /// /// /// See for how a key is resolved at record time. From 63814843e53a9e36835ba5dbac96ab86868a5b81 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 16:58:32 -0700 Subject: [PATCH 05/34] Revert documentation changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .github/instructions/IDE.instructions.md | 8 -- .github/memory/INDEX.md | 1 - .github/memory/known-issues/razor.md | 20 +--- .github/memory/telemetry.md | 123 ----------------------- 4 files changed, 1 insertion(+), 151 deletions(-) delete mode 100644 .github/memory/telemetry.md diff --git a/.github/instructions/IDE.instructions.md b/.github/instructions/IDE.instructions.md index 1eefc301a2649..f6a1e030f6da1 100644 --- a/.github/instructions/IDE.instructions.md +++ b/.github/instructions/IDE.instructions.md @@ -59,14 +59,6 @@ public MyService(IDependency dependency) { } - ServiceHub components live under `src/Workspaces/Remote/` and have special deployment considerations for .NET Core vs .NET Framework — keep both targets in mind when changing remote services -## Telemetry & Logging - -- Record events and scopes with `RoslynTelemetry.Log` / `RoslynTelemetry.LogBlock`; record aggregated measurements with `RoslynTelemetry.Count` / `.Record` / `.RecordBlockTime`, passing dimensions as tags rather than concatenating them into the metric name. -- `Logger.Log` / `Logger.LogBlock` are a forwarding shim kept so existing call sites did not all have to change at once. Do not add new call sites to them. -- Sink composition is fixed at host startup. To turn a sink on or off, update its `IsEnabled` predicate on the already-composed instance (`UpdatePredicate`); never add or remove a sink from the list, which is how a sink ends up registered twice and posting everything twice. -- Consent is a sink-level gate (`session.IsOptedIn`), never a call-site check. -- Full detail — sink contracts, `VSMetricSink` aggregation invariants, per-host wiring — is in `.github/memory/telemetry.md`. Read it before changing anything under `Internal.Log` or `src/VisualStudio/Core/Def/Telemetry/`. - ## Key Development Patterns ### TestAccessor Pattern diff --git a/.github/memory/INDEX.md b/.github/memory/INDEX.md index 75b3c66671c44..910990397e16c 100644 --- a/.github/memory/INDEX.md +++ b/.github/memory/INDEX.md @@ -17,7 +17,6 @@ This is the loading map for the agent knowledge base under `.github/memory/`. ** | **`API_MAP.md`** | Build/test entry points & PublicAPI tracking | When changing build, tests, or public APIs | | **`KNOWN_ISSUES.md`** | Repo-wide / cross-cutting quirks & workarounds | Always for code review; unfamiliar areas | | **`TESTING_STRATEGY.md`** | Test layout, shared authoring conventions & how to run tests | When writing tests or debugging test failures | -| **`telemetry.md`** | Telemetry & logging: event/metric sinks, composition, consent, per-host wiring | When adding telemetry, changing logging, or touching `Internal.Log` | ## Layer-specific knowledge diff --git a/.github/memory/known-issues/razor.md b/.github/memory/known-issues/razor.md index a3f03635fa210..2405b2e81d695 100644 --- a/.github/memory/known-issues/razor.md +++ b/.github/memory/known-issues/razor.md @@ -1,4 +1,4 @@ ---- +--- coverage: Razor-layer (src/Razor) known issues, quirks & workarounds --- @@ -76,21 +76,3 @@ the project may be built without being independently published. `ComputeResolvedFilesToPublishList`. Set `PostprocessAssembly=true` on managed assemblies so ReadyToRun replaces them with the RID-specific output, and preserve `RecursiveDir` in satellite-resource `RelativePath` metadata. - -## Razor still has its own telemetry aggregation, duplicating `VSMetricSink` - -**Affected area:** `Microsoft.VisualStudio.LanguageServices.Razor/Telemetry/` -**Description:** Razor's `AggregatingTelemetryLog` / `AggregatingTelemetryLogManager`, plus the request -`Counter` nested in `TelemetryReporter.TelemetrySessionManager`, wrap the same -`Microsoft.VisualStudio.Telemetry.Metrics` surface that Roslyn's `VSMetricSink` does. The assembly graph -does *not* prevent sharing: `Microsoft.CodeAnalysis.Remote.ServiceHub` already links -`src/VisualStudio/Core/Def/Telemetry/Shared/*.cs`, and `Microsoft.CodeAnalysis.Remote.Razor` already has a -`ProjectReference` to it, so an `InternalsVisibleTo` grant is all that is missing for the VS and OOP hosts. - -**What actually blocks it** is the VS Code host. `VSCodeTelemetryReporter` owns no `TelemetrySession`; it -overrides `Report` / `ReportMetric` to forward flattened events through -`ILanguageServerTelemetryReporterWrapper` into Roslyn's reporter, and the dependency runs Roslyn → Razor, -so Razor's VS Code extension cannot reach a Roslyn `IMetricSink` instance. Consolidating requires growing -that wrapper interface with `Count`/`Record` methods, which changes the emitted shape of Razor's VS Code -metrics from flattened events on Roslyn's session to metric events — a Razor-owned telemetry change that -needs its own dashboard validation. diff --git a/.github/memory/telemetry.md b/.github/memory/telemetry.md deleted file mode 100644 index 00f125843d5e9..0000000000000 --- a/.github/memory/telemetry.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -coverage: Roslyn's telemetry and logging architecture - event sinks, metric sinks, host composition, and how each host (VS, OOP, standalone LSP, build server, tests, Razor) wires them up ---- - -# Telemetry & Logging - -## The two call-site APIs - -Everything Roslyn records goes through one of three families. Pick by *what you are recording*, not by -which host you are in. - -| Recording | Call | Available in | -|---|---|---| -| A discrete event or a timed scope | `RoslynTelemetry.Log(FunctionId, ...)` / `RoslynTelemetry.LogBlock(FunctionId, ...)` | Every layer, including the CodeStyle packages | -| An aggregated measurement | `RoslynTelemetry.Count(FunctionId, metricName, delta, tags...)` / `.Record(...)` / `.RecordBlockTime(...)` | Every layer | -| A reliability failure | `FatalError.ReportAndCatch(...)` and friends | Every layer | - -All live in `Microsoft.CodeAnalysis.Internal.Log` -(`src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/`), which is linked source compiled into -~15 assemblies. With no sink configured every method is a cheap no-op — that is what the build server, -the CodeStyle packages, and most tests rely on. - -`Logger.Log` / `Logger.LogBlock` still exist as a thin forwarding shim onto `RoslynTelemetry` so that the -150+ existing call sites did not have to change in one go. **New code should call `RoslynTelemetry` -directly.** The shim carries no `[Obsolete]` because the repository builds with warnings as errors. - -## Sinks - -``` -call site ──► RoslynTelemetry ──┬─► IEventSink (events + scopes) - └─► IMetricSink (aggregated measurements) -``` - -- **`IEventSink`** — `IsEnabled(FunctionId)`, `Log`, `LogBlockStart`, `LogBlockEnd`. `IsEnabled` is - consulted *before* any `LogMessage` is constructed, so a disabled sink costs nothing. This is where - both consent (telemetry sinks return `session.IsOptedIn`) and opt-in enablement (diagnostic sinks - consult a predicate) live. -- **`IMetricSink`** — `Count`, `Record`, `Flush`. Deliberately free of any telemetry-backend or BCL - metrics type so it can live in the dependency-minimal shared layer, and keyed by a plain - `string eventName` rather than `FunctionId` so Razor can share the implementation. - `FunctionId` → event-name mapping happens one level up, in `TelemetryNaming`. - -`TelemetryNaming` is the only place the `vs/ide/vbcs/` and `vs.ide.vbcs.` conventions appear. - -## Composition is fixed; enablement is not - -A host builds its sink list **once**, at startup, via `AggregateEventSink.Create(...)`, and never mutates -it. Turning a sink off means its own `IsEnabled` returns false — not removing it from the list. This is -load-bearing: a sink registered twice posts every event twice, and the previous predicate-based -add/replace/remove API made that easy to do by accident. - -- `EtwLogger`, `TraceLogger`, `OutputWindowLogger` expose `UpdatePredicate(...)`; the Performance Loggers - options page refreshes the **composed instances** through - `VisualStudioWorkspaceTelemetryService.UpdateDiagnosticSinkEnablement` (and its OOP mirror on - `RemoteWorkspaceTelemetryService`). -- `RoslynActivityLogger.Sink` is composed once and holds an `ImmutableArray`; adding and - removing a `TraceSource` mutates that set, not the sink list. -- Sinks that live in assemblies the composition root cannot reference (the diagnostics tool window VSIX, - integration tests) attach themselves once with `RoslynTelemetry.AddEventSink` and are thereafter - controlled by their predicate. They never detach. - -## Aggregation: `VSMetricSink` - -`src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs` is the single aggregating implementation, -backed by VS Telemetry's `IMeter`/`ICounter`/`IHistogram`. The `Shared` folder is linked into -`Microsoft.VisualStudio.LanguageServices`, `Microsoft.CodeAnalysis.Remote.ServiceHub`, and -`Microsoft.CodeAnalysis.LanguageServer`, so all three hosts compile their own copy. - -Three properties worth knowing before changing it: - -1. **Buckets are keyed by `(TelemetrySessionKey, eventName, metricName, dimensionKey)`.** The session key - is a constant today; it exists so aggregation state is never keyed on the assumption of a single - session. A process running several language servers (daemon mode) needs each server's measurements - bucketed and posted separately, and retrofitting that later would be a rewrite of the aggregation - rather than a configuration change. -2. **`dimensionKey` is the tag values concatenated in declaration order.** This reproduces the compound - string call sites used to build by hand (`server.method.language`), so migrating a call site to tags - does not change which measurements aggregate together. -3. **`Flush()` is global and clears everything.** It posts each bucket to the session that produced it. - Clearing on flush is also what keeps a long-lived process from accruing buckets for ended sessions. - The two-level lock (`_flushLock` plus a per-aggregation lock) is required — see - https://github.com/dotnet/roslyn/pull/71606, where concurrent `PostMetricEvent` calls for one - instrument were crashing. - -`VSMetricSink.IMetricPoster` is the per-session seam that lets tests assert exactly how many events a -flush posts without standing up a real, opted-in `TelemetrySession`. - -## Per-host wiring - -| Host | Entry point | Sinks | -|---|---|---| -| **Visual Studio** | `VisualStudioWorkspaceTelemetryService.CreateLogger` via `AbstractWorkspaceTelemetryService.InitializeTelemetrySession` | `CodeMarkerLogger`, `EtwLogger`, `TraceLogger`, `RoslynActivityLogger.Sink`, `TelemetryLogger`, `FileLogger` + `VSMetricSink` | -| **ServiceHub / OOP** | `RemoteWorkspaceTelemetryService.CreateLogger`; VS serializes its session and RPCs `InitializeTelemetrySessionAsync` | `EtwLogger`, `TraceLogger`, `TelemetryLogger` + `VSMetricSink` | -| **Standalone LSP** | `LanguageServerTelemetryService.InitializeSession`, called from `Program.cs` | `TelemetryLogger` + `VSMetricSink` | -| **VBCSCompiler** | `BuildServerController.RunServer` | none — uses `ICompilerServerLogger` only, by design | -| **Tests** | `UseExportProviderAttribute` resets sinks after every test | none by default | - -`AbstractWorkspaceTelemetryService` also starts the 30-minute periodic `RoslynTelemetry.Flush()`. Shutdown -paths flush explicitly as well, because a host can exit too abruptly for the timer to run -(https://github.com/dotnet/roslyn/pull/73287). - -## Consent - -Consent is a **sink-level, non-bypassable** gate, never a call-site decision. `TelemetryLogger.IsEnabled` -returns `session.IsOptedIn`, and `VSMetricSink` checks `IMetricPoster.IsOptedIn` before building any -aggregation. Because `RoslynTelemetry` consults `IEventSink.IsEnabled` before constructing a -`LogMessage`, an opted-out session allocates nothing at all -(https://github.com/dotnet/roslyn/pull/52484). - -## Razor - -Razor keeps its own call-site facade (`Microsoft.CodeAnalysis.Razor.Telemetry.ITelemetryReporter`), which -is already tag-shaped (`Property` is a name/value pair and the overloads are `ReadOnlySpan`). -It still has its own aggregation implementation (`AggregatingTelemetryLog`, -`AggregatingTelemetryLogManager`, and the request `Counter` inside `TelemetryReporter`), which duplicates -`VSMetricSink`. Consolidating it is tracked separately — see `.github/memory/known-issues/razor.md`. - -Razor's VS Code extension owns no telemetry session; it posts through the language server host's session -via `ILanguageServerTelemetryReporterWrapper` (declared in Razor's assembly, implemented on the Roslyn -side by `TelemetryReporterWrapper`, because the dependency runs Roslyn → Razor). That wrapper forwards -`TelemetryMetricEvent`s **intact**. Flattening one to a name and property bag silently drops every -aggregated value, since those live on the event's instrument and are only read by -`TelemetrySession.PostMetricEvent` — this was a real defect in that path. From d91ab67b8d10fb83f558f6f2aadef05bedd27db5 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 17:27:53 -0700 Subject: [PATCH 06/34] Fan out events from RoslynTelemetry; delete AggregateEventSink AggregateEventSink.IsEnabled returned true unconditionally, so TryGetActiveEventSink always succeeded whenever a composite was registered and every Logger.Log call built a LogMessage -- and LogBlock rented a pooled block and burned a block id -- before the composite's dispatch loop discovered that all of its children were disabled. That is the common case, not a corner: VS composes six sinks and all six are normally off (ETW predicate false, Trace false, CodeMarkers off, TraceSource set empty, File off, and TelemetryLogger false whenever the user is opted out). On a path sized for 1-10k events/second, the pooling exists precisely to avoid that allocation. RoslynTelemetry now holds the sinks itself, so the guard asks whether any sink wants the FunctionId before anything is constructed. Every IsEnabled implementation is a bool read, a predicate call, or a dictionary lookup, so consulting them twice -- once to decide, once per sink while dispatching -- costs far less than what it avoids. Folding the fan-out in also removes the reason AggregateEventSink existed at all. AddEventSink is now an interlocked array append rather than wrapping the existing sink in a new composite, which is what forced Create to flatten nested aggregates and dedupe through a HashSet. And no type is left in the middle that has to answer IsEnabled on behalf of others. RoslynLogBlock captures the sink array at start, so a block still ends on exactly the sinks it started on. Validation: Compilers.slnf builds clean; Ide.slnf and Razor.slnf build clean apart from Microsoft.VisualStudio.Extensibility.Testing.Xunit, which fails identically on a clean tree at HEAD (Arcade RepositoryCommit, unrelated). LSP telemetry tests 18/18, Razor telemetry tests 19/19. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../LanguageServerTelemetryService.cs | 4 +- .../AbstractWorkspaceTelemetryService.cs | 5 +- .../VisualStudioWorkspaceTelemetryService.cs | 9 +- .../Services/ServiceHubServicesTests.cs | 4 +- ...xtViewWindowVerifierInProcessExtensions.cs | 14 +- .../Log/RoslynTelemetry.Workspaces.cs | 2 +- .../MEF/UseExportProviderAttribute.cs | 2 +- .../RemoteWorkspaceTelemetryService.cs | 9 +- .../Core/CompilerExtensions.projitems | 3 +- .../Compiler/Core/Log/AggregateEventSink.cs | 90 ------------ .../Core/Log/RoslynTelemetry.LogBlock.cs | 31 +++-- .../Compiler/Core/Log/RoslynTelemetry.cs | 128 ++++++++++-------- 12 files changed, 117 insertions(+), 184 deletions(-) delete mode 100644 src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs index f4a2ab5f5ded6..b2f177c908d4b 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs @@ -83,7 +83,7 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD _telemetrySession = session; - RoslynTelemetry.SetEventSink(AggregateEventSink.Create(RoslynTelemetry.GetEventSink(), TelemetryLogger.Create(session, logDelta: false))); + RoslynTelemetry.SetEventSinks([.. RoslynTelemetry.GetEventSinks(), TelemetryLogger.Create(session, logDelta: false)]); FaultReporter.InitializeFatalErrorHandlers(); FaultReporter.IncludeServiceHubLogFiles = false; @@ -114,7 +114,7 @@ public void Dispose() if (_telemetrySession is { } session) { - RoslynTelemetry.SetEventSink(null); + RoslynTelemetry.SetEventSinks([]); FaultReporter.UnregisterTelemetrySesssion(session); session.Dispose(); diff --git a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs index ed6679b378068..07e356f291bc0 100644 --- a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; @@ -18,13 +19,13 @@ internal abstract class AbstractWorkspaceTelemetryService : IWorkspaceTelemetryS { public TelemetrySession? CurrentSession { get; private set; } - protected abstract IEventSink CreateLogger(TelemetrySession telemetrySession, bool logDelta); + protected abstract ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta); public void InitializeTelemetrySession(TelemetrySession telemetrySession, bool logDelta) { Contract.ThrowIfFalse(CurrentSession is null); - RoslynTelemetry.SetEventSink(CreateLogger(telemetrySession, logDelta)); + RoslynTelemetry.SetEventSinks(CreateEventSinks(telemetrySession, logDelta)); VSMetricSink.Create(telemetrySession); FaultReporter.RegisterTelemetrySesssion(telemetrySession); diff --git a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs index 431d60ac65199..cecc4ff07118f 100644 --- a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Immutable; using System.Composition; using System.Diagnostics; using System.Threading.Tasks; @@ -39,19 +40,21 @@ internal sealed class VisualStudioWorkspaceTelemetryService( private EtwLogger? _etwLogger; private TraceLogger? _traceLogger; - protected override IEventSink CreateLogger(TelemetrySession telemetrySession, bool logDelta) + protected override ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta) { _etwLogger = new EtwLogger(FunctionIdOptions.CreateFunctionIsEnabledPredicate(_globalOptions)); _traceLogger = new TraceLogger(EtwLogger.DisabledPredicate); - return AggregateEventSink.Create( + return + [ + .. RoslynTelemetry.GetEventSinks(), CodeMarkerLogger.Instance, _etwLogger, _traceLogger, RoslynActivityLogger.Sink, TelemetryLogger.Create(telemetrySession, logDelta), new FileLogger(_globalOptions, _threadingContext), - RoslynTelemetry.GetEventSink()); + ]; } /// diff --git a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs index 74dc0aee2a1d6..331df7450dce1 100644 --- a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs +++ b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs @@ -1949,13 +1949,13 @@ void M() Assert.Equal("CSharp.ConflictMarkerResolution.CSharpResolveConflictMarkerCodeFixProvider", result.CodeFixAnalysis.DiagnosticIdToProviderName["CS8300"].Single()); var logger = new TestTelemetryLogger(); - RoslynTelemetry.SetEventSink(logger); + RoslynTelemetry.SetEventSinks([logger]); TestTelemetryLogger.TestScope scope; using (CopilotChangeAnalysisUtilities.LogCopilotChangeAnalysis("TestCode", accepted: true, "TestProposalId", result, CancellationToken.None)) { scope = logger.OpenedScopes.Single(); } - RoslynTelemetry.SetEventSink(null); + RoslynTelemetry.SetEventSinks([]); var endEvent = scope.EndEvent; Assert.Equal("vs/ide/vbcs/copilot/analyzechange", endEvent.Name); diff --git a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs index e39ac5988ef92..af32e241103e3 100644 --- a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs +++ b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs @@ -100,7 +100,7 @@ public static async Task CodeActionAsync( if (!RoslynString.IsNullOrEmpty(applyFix)) { var codeActionLogger = new CodeActionLogger(); - using var loggerRestorer = WithLogger(AggregateEventSink.Create(RoslynTelemetry.GetEventSink(), codeActionLogger)); + using var loggerRestorer = WithLogger([.. RoslynTelemetry.GetEventSinks(), codeActionLogger]); var result = await textViewWindowVerifier.TestServices.Editor.ApplyLightBulbActionAsync(applyFix, fixAllScope, blockUntilComplete, cancellationToken); @@ -154,9 +154,9 @@ await textViewWindowVerifier.TestServices.Workspace.WaitForAllAsyncOperationsAsy Assert.NotEqual("text", tokenType); } - private static LoggerRestorer WithLogger(IEventSink logger) + private static LoggerRestorer WithLogger(ImmutableArray sinks) { - return new LoggerRestorer(RoslynTelemetry.SetEventSink(logger)); + return new LoggerRestorer(RoslynTelemetry.SetEventSinks(sinks)); } private sealed class CodeActionLogger : IEventSink @@ -190,16 +190,16 @@ public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniq private readonly struct LoggerRestorer : IDisposable { - private readonly IEventSink? _logger; + private readonly ImmutableArray _sinks; - public LoggerRestorer(IEventSink? logger) + public LoggerRestorer(ImmutableArray sinks) { - _logger = logger; + _sinks = sinks; } public void Dispose() { - RoslynTelemetry.SetEventSink(_logger); + RoslynTelemetry.SetEventSinks(_sinks); } } } diff --git a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs index 18d67c787ff79..0271a900b8bab 100644 --- a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs +++ b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs @@ -31,7 +31,7 @@ internal static partial class RoslynTelemetry /// its own event. /// public static IDisposable? LogBlockTime(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs = -1) - => GetEventSink() is null ? null : new TimedEventBlock(functionId, logMessage, minThresholdMs); + => GetEventSinks().IsEmpty ? null : new TimedEventBlock(functionId, logMessage, minThresholdMs); private sealed class TimedEventBlock : IDisposable { diff --git a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs index c7e8a3f90acc3..c6929b2dfe5f0 100644 --- a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs +++ b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs @@ -101,7 +101,7 @@ public override void After(MethodInfo? methodUnderTest) // Reset static state variables. _hostServices = null; ExportProviderCache.SetEnabled_OnlyUseExportProviderAttributeCanCall(false); - RoslynTelemetry.SetEventSink(null); + RoslynTelemetry.SetEventSinks([]); } } diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs index c94fc0729dda6..dd8e8740f93b7 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Immutable; using System.Composition; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.Internal.Log; @@ -23,16 +24,18 @@ internal sealed class RemoteWorkspaceTelemetryService() : AbstractWorkspaceTelem private EtwLogger? _etwLogger; private TraceLogger? _traceLogger; - protected override IEventSink CreateLogger(TelemetrySession telemetrySession, bool logDelta) + protected override ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta) { _etwLogger = new EtwLogger(EtwLogger.DisabledPredicate); _traceLogger = new TraceLogger(EtwLogger.DisabledPredicate); - return AggregateEventSink.Create( + return + [ + .. RoslynTelemetry.GetEventSinks(), _etwLogger, _traceLogger, TelemetryLogger.Create(telemetrySession, logDelta), - RoslynTelemetry.GetEventSink()); + ]; } /// diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems index 16e248a5e02d4..8e1d9e8a70e9a 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems @@ -332,7 +332,6 @@ - @@ -548,4 +547,4 @@ - \ No newline at end of file + diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs deleted file mode 100644 index a0a49790ee77b..0000000000000 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/AggregateEventSink.cs +++ /dev/null @@ -1,90 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Threading; - -namespace Microsoft.CodeAnalysis.Internal.Log; - -/// -/// Fans an event out to a fixed set of sinks. The set is decided once, when a host composes its -/// telemetry, and is not mutated afterwards: turning a sink off is that sink's own -/// returning false. Keeping the set fixed is what guarantees a sink -/// cannot be registered twice, which would post its events twice. -/// -internal sealed class AggregateEventSink : IEventSink -{ - private readonly ImmutableArray _sinks; - - private AggregateEventSink(ImmutableArray sinks) - => _sinks = sinks; - - public static AggregateEventSink Create(params IEventSink?[] sinks) - { - var set = new HashSet(); - - // flatten nested aggregates so a sink can never appear twice - foreach (var sink in sinks) - { - if (sink is null) - continue; - - if (sink is AggregateEventSink aggregate) - { - set.UnionWith(aggregate._sinks); - continue; - } - - set.Add(sink); - } - - return new AggregateEventSink([.. set]); - } - - public bool IsEnabled(FunctionId functionId) - => true; - - public void Log(FunctionId functionId, LogMessage logMessage) - { - for (var i = 0; i < _sinks.Length; i++) - { - var sink = _sinks[i]; - if (!sink.IsEnabled(functionId)) - { - continue; - } - - sink.Log(functionId, logMessage); - } - } - - public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken) - { - for (var i = 0; i < _sinks.Length; i++) - { - var sink = _sinks[i]; - if (!sink.IsEnabled(functionId)) - { - continue; - } - - sink.LogBlockStart(functionId, logMessage, uniquePairId, cancellationToken); - } - } - - public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken) - { - for (var i = 0; i < _sinks.Length; i++) - { - var sink = _sinks[i]; - if (!sink.IsEnabled(functionId)) - { - continue; - } - - sink.LogBlockEnd(functionId, logMessage, uniquePairId, delta, cancellationToken); - } - } -} diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs index ae9f8e29a6064..8cf020aea7ec8 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Immutable; using System.Threading; using Microsoft.CodeAnalysis.PooledObjects; using Roslyn.Utilities; @@ -16,12 +17,10 @@ internal static partial class RoslynTelemetry // Use an object pool since we may be logging up to 1-10k events/second private static readonly ObjectPool s_pool = new(() => new RoslynLogBlock(s_pool!), Math.Min(Environment.ProcessorCount * 8, 256)); - public static IDisposable CreateLogBlock(IEventSink sink, FunctionId functionId, LogMessage message, int blockId, CancellationToken cancellationToken) + public static IDisposable CreateLogBlock(ImmutableArray sinks, FunctionId functionId, LogMessage message, int blockId, CancellationToken cancellationToken) { - Contract.ThrowIfNull(sink); - var block = s_pool.Allocate(); - block.Construct(sink, functionId, message, blockId, cancellationToken); + block.Construct(sinks, functionId, message, blockId, cancellationToken); return block; } @@ -33,7 +32,7 @@ private sealed class RoslynLogBlock(ObjectPool pool) : IDisposab { // these need to be cleared before putting back to pool - private IEventSink? _sink; + private ImmutableArray _sinks; private LogMessage? _logMessage; private CancellationToken _cancellationToken; @@ -41,21 +40,25 @@ private sealed class RoslynLogBlock(ObjectPool pool) : IDisposab private int _tick; private int _blockId; - public void Construct(IEventSink sink, FunctionId functionId, LogMessage logMessage, int blockId, CancellationToken cancellationToken) + public void Construct(ImmutableArray sinks, FunctionId functionId, LogMessage logMessage, int blockId, CancellationToken cancellationToken) { - _sink = sink; + _sinks = sinks; _functionId = functionId; _logMessage = logMessage; _tick = Environment.TickCount; _blockId = blockId; _cancellationToken = cancellationToken; - sink.LogBlockStart(functionId, logMessage, blockId, cancellationToken); + foreach (var sink in sinks) + { + if (sink.IsEnabled(functionId)) + sink.LogBlockStart(functionId, logMessage, blockId, cancellationToken); + } } public void Dispose() { - if (_sink == null) + if (_sinks.IsDefaultOrEmpty) { return; } @@ -65,12 +68,18 @@ public void Dispose() // This delta is valid for durations of < 25 days var delta = Environment.TickCount - _tick; - _sink.LogBlockEnd(_functionId, _logMessage, _blockId, delta, _cancellationToken); + // Ends on exactly the sinks the block started on: _sinks is the snapshot taken then, so a + // sink added or enabled in between cannot see an unpaired end. + foreach (var sink in _sinks) + { + if (sink.IsEnabled(_functionId)) + sink.LogBlockEnd(_functionId, _logMessage, _blockId, delta, _cancellationToken); + } // Free this block back to the pool _logMessage.Free(); _logMessage = null; - _sink = null; + _sinks = default; _cancellationToken = default; pool.Free(this); diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index ba80a607968b3..f6a37e4e09ba1 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -3,23 +3,30 @@ // See the LICENSE file in the project root for more information. using System; -using System.Diagnostics.CodeAnalysis; +using System.Collections.Generic; +using System.Collections.Immutable; using System.Threading; namespace Microsoft.CodeAnalysis.Internal.Log; /// /// Roslyn's telemetry entry point. Discrete events and scopes are recorded here and fan out to the -/// host's configured ; aggregated measurements go to its . +/// host's configured s; aggregated measurements go to its . /// -/// A host configures this once at startup (see / ). +/// A host configures this once at startup (see / ). /// With nothing configured every method is a cheap no-op, which is the state the build server, the /// CodeStyle packages, and most tests run in. /// /// internal static partial class RoslynTelemetry { - private static IEventSink? s_currentEventSink; + /// + /// The sinks every event fans out to. Decided once, when a host composes its telemetry, and not + /// mutated afterwards: turning a sink off is that sink's own + /// returning false. Keeping the set fixed is what guarantees a sink cannot be registered twice, + /// which would post its events twice. + /// + private static ImmutableArray s_eventSinks = []; /// /// next unique block id that will be given to each LogBlock @@ -27,57 +34,58 @@ internal static partial class RoslynTelemetry private static int s_lastUniqueBlockId; /// - /// Replaces the active event sink. Hosts call this once during startup; tests reset it to - /// during teardown. + /// Replaces the active sinks. Hosts call this once during startup; tests reset it to empty during + /// teardown. Returns what was previously registered. /// - public static IEventSink? SetEventSink(IEventSink? sink) - { - // we don't care what was there already, just replace it explicitly - return Interlocked.Exchange(ref s_currentEventSink, sink); - } + public static ImmutableArray SetEventSinks(ImmutableArray sinks) + => ImmutableInterlocked.InterlockedExchange(ref s_eventSinks, sinks); - public static IEventSink? GetEventSink() - => s_currentEventSink; + public static ImmutableArray GetEventSinks() + => s_eventSinks; /// - /// Atomically adds alongside whatever is already registered. Used by - /// diagnostic sinks that live in assemblies the composition root cannot reference (the diagnostics - /// tool window, integration tests). Such a sink attaches once and is thereafter controlled by its - /// own ; it is never detached. + /// Atomically adds alongside whatever is already registered, ignoring it if + /// it is already present. Used by diagnostic sinks that live in assemblies the composition root + /// cannot reference (the diagnostics tool window, integration tests). Such a sink attaches once and + /// is thereafter controlled by its own ; it is never detached. /// public static void AddEventSink(IEventSink sink) + => ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Contains(sink) ? sinks : sinks.Add(sink), sink); + + /// + /// Whether any registered sink wants . Checked before a + /// is constructed, so that logging costs nothing when everything is + /// disabled - which is the common case, since most sinks are opt-in diagnostics. + /// + private static bool TryGetEnabledSinks(FunctionId functionId, out ImmutableArray sinks) { - while (true) + sinks = s_eventSinks; + + foreach (var sink in sinks) { - var existing = s_currentEventSink; - var combined = existing is null ? sink : AggregateEventSink.Create(existing, sink); - if (Interlocked.CompareExchange(ref s_currentEventSink, combined, existing) == existing) - return; + if (sink.IsEnabled(functionId)) + return true; } + + return false; } - private static bool TryGetActiveEventSink(FunctionId functionId, [NotNullWhen(true)] out IEventSink? activeSink) + private static void LogToSinks(ImmutableArray sinks, FunctionId functionId, LogMessage logMessage) { - var sink = s_currentEventSink; - if (sink == null || !sink.IsEnabled(functionId)) + foreach (var sink in sinks) { - activeSink = null; - return false; + if (sink.IsEnabled(functionId)) + sink.Log(functionId, logMessage); } - - activeSink = sink; - return true; } - - /// /// log a specific event with a simple context message which should be very cheap to create /// public static void Log(FunctionId functionId, string? message = null, LogLevel logLevel = LogLevel.Debug) { - if (TryGetActiveEventSink(functionId, out var sink)) + if (TryGetEnabledSinks(functionId, out var sinks)) { - sink.Log(functionId, LogMessage.Create(message ?? "", logLevel: logLevel)); + LogToSinks(sinks, functionId, LogMessage.Create(message ?? "", logLevel: logLevel)); } } @@ -87,10 +95,10 @@ public static void Log(FunctionId functionId, string? message = null, LogLevel l /// public static void Log(FunctionId functionId, Func messageGetter, LogLevel logLevel = LogLevel.Debug) { - if (TryGetActiveEventSink(functionId, out var sink)) + if (TryGetEnabledSinks(functionId, out var sinks)) { var logMessage = LogMessage.Create(messageGetter, logLevel); - sink.Log(functionId, logMessage); + LogToSinks(sinks, functionId, logMessage); logMessage.Free(); } @@ -102,10 +110,10 @@ public static void Log(FunctionId functionId, Func messageGetter, LogLev /// public static void Log(FunctionId functionId, Func messageGetter, TArg arg, LogLevel logLevel = LogLevel.Debug) { - if (TryGetActiveEventSink(functionId, out var sink)) + if (TryGetEnabledSinks(functionId, out var sinks)) { var logMessage = LogMessage.Create(messageGetter, arg, logLevel); - sink.Log(functionId, logMessage); + LogToSinks(sinks, functionId, logMessage); logMessage.Free(); } } @@ -116,10 +124,10 @@ public static void Log(FunctionId functionId, Func messageGe /// public static void Log(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, LogLevel logLevel = LogLevel.Debug) { - if (TryGetActiveEventSink(functionId, out var sink)) + if (TryGetEnabledSinks(functionId, out var sinks)) { var logMessage = LogMessage.Create(messageGetter, arg0, arg1, logLevel); - sink.Log(functionId, logMessage); + LogToSinks(sinks, functionId, logMessage); logMessage.Free(); } } @@ -130,10 +138,10 @@ public static void Log(FunctionId functionId, Func public static void Log(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, LogLevel logLevel = LogLevel.Debug) { - if (TryGetActiveEventSink(functionId, out var sink)) + if (TryGetEnabledSinks(functionId, out var sinks)) { var logMessage = LogMessage.Create(messageGetter, arg0, arg1, arg2, logLevel); - sink.Log(functionId, logMessage); + LogToSinks(sinks, functionId, logMessage); logMessage.Free(); } } @@ -144,10 +152,10 @@ public static void Log(FunctionId functionId, Func public static void Log(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, TArg3 arg3, LogLevel logLevel = LogLevel.Debug) { - if (TryGetActiveEventSink(functionId, out var sink)) + if (TryGetEnabledSinks(functionId, out var sinks)) { var logMessage = LogMessage.Create(messageGetter, arg0, arg1, arg2, arg3, logLevel); - sink.Log(functionId, logMessage); + LogToSinks(sinks, functionId, logMessage); logMessage.Free(); } } @@ -157,9 +165,9 @@ public static void Log(FunctionId functionId, Func public static void Log(FunctionId functionId, LogMessage logMessage) { - if (TryGetActiveEventSink(functionId, out var sink)) + if (TryGetEnabledSinks(functionId, out var sinks)) { - sink.Log(functionId, logMessage); + LogToSinks(sinks, functionId, logMessage); logMessage.Free(); } } @@ -180,8 +188,8 @@ public static IDisposable LogBlock(FunctionId functionId, CancellationToken toke /// simplest way to log a start and end pair with a simple context message which should be very cheap to create /// public static IDisposable LogBlock(FunctionId functionId, string? message, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveEventSink(functionId, out var sink) - ? CreateLogBlock(sink, functionId, LogMessage.Create(message ?? "", logLevel), GetNextUniqueBlockId(), token) + => TryGetEnabledSinks(functionId, out var sinks) + ? CreateLogBlock(sinks, functionId, LogMessage.Create(message ?? "", logLevel), GetNextUniqueBlockId(), token) : EmptyLogBlock.Instance; /// @@ -189,8 +197,8 @@ public static IDisposable LogBlock(FunctionId functionId, string? message, Cance /// the messageGetter should be cheap to create. in another word, it shouldn't capture any locals /// public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveEventSink(functionId, out var sink) - ? CreateLogBlock(sink, functionId, LogMessage.Create(messageGetter, logLevel), GetNextUniqueBlockId(), token) + => TryGetEnabledSinks(functionId, out var sinks) + ? CreateLogBlock(sinks, functionId, LogMessage.Create(messageGetter, logLevel), GetNextUniqueBlockId(), token) : EmptyLogBlock.Instance; /// @@ -198,8 +206,8 @@ public static IDisposable LogBlock(FunctionId functionId, Func messageGe /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals /// public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg arg, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveEventSink(functionId, out var sink) - ? CreateLogBlock(sink, functionId, LogMessage.Create(messageGetter, arg, logLevel), GetNextUniqueBlockId(), token) + => TryGetEnabledSinks(functionId, out var sinks) + ? CreateLogBlock(sinks, functionId, LogMessage.Create(messageGetter, arg, logLevel), GetNextUniqueBlockId(), token) : EmptyLogBlock.Instance; /// @@ -207,8 +215,8 @@ public static IDisposable LogBlock(FunctionId functionId, Func public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveEventSink(functionId, out var sink) - ? CreateLogBlock(sink, functionId, LogMessage.Create(messageGetter, arg0, arg1, logLevel), GetNextUniqueBlockId(), token) + => TryGetEnabledSinks(functionId, out var sinks) + ? CreateLogBlock(sinks, functionId, LogMessage.Create(messageGetter, arg0, arg1, logLevel), GetNextUniqueBlockId(), token) : EmptyLogBlock.Instance; /// @@ -216,8 +224,8 @@ public static IDisposable LogBlock(FunctionId functionId, Func public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveEventSink(functionId, out var sink) - ? CreateLogBlock(sink, functionId, LogMessage.Create(messageGetter, arg0, arg1, arg2, logLevel), GetNextUniqueBlockId(), token) + => TryGetEnabledSinks(functionId, out var sinks) + ? CreateLogBlock(sinks, functionId, LogMessage.Create(messageGetter, arg0, arg1, arg2, logLevel), GetNextUniqueBlockId(), token) : EmptyLogBlock.Instance; /// @@ -225,15 +233,15 @@ public static IDisposable LogBlock(FunctionId functionId, F /// given arguments will be passed to the messageGetter so that it can create the context message without requiring lifted locals /// public static IDisposable LogBlock(FunctionId functionId, Func messageGetter, TArg0 arg0, TArg1 arg1, TArg2 arg2, TArg3 arg3, CancellationToken token, LogLevel logLevel = LogLevel.Trace) - => TryGetActiveEventSink(functionId, out var sink) - ? CreateLogBlock(sink, functionId, LogMessage.Create(messageGetter, arg0, arg1, arg2, arg3, logLevel), GetNextUniqueBlockId(), token) + => TryGetEnabledSinks(functionId, out var sinks) + ? CreateLogBlock(sinks, functionId, LogMessage.Create(messageGetter, arg0, arg1, arg2, arg3, logLevel), GetNextUniqueBlockId(), token) : EmptyLogBlock.Instance; /// /// log a start and end pair with a context message. /// public static IDisposable LogBlock(FunctionId functionId, LogMessage logMessage, CancellationToken token) - => TryGetActiveEventSink(functionId, out var sink) - ? CreateLogBlock(sink, functionId, logMessage, GetNextUniqueBlockId(), token) + => TryGetEnabledSinks(functionId, out var sinks) + ? CreateLogBlock(sinks, functionId, logMessage, GetNextUniqueBlockId(), token) : EmptyLogBlock.Instance; } From de78c129134729086cdf34c88a474dd12a18fe92 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 17:42:40 -0700 Subject: [PATCH 07/34] Drop the unused ambient session plumbing; one metric sink per session The AsyncLocal session key, s_ambientRoutingEnabled, TelemetrySessionKey, and VSMetricSink.RegisterSession had no callers and CurrentSessionKey always returned Default. Worse, they pre-committed to a shape -- one sink holding many sessions, routed by ambient key -- that made per-session routing hard to reason about, since selecting a session and selecting a destination were two separate half-built mechanisms. A VSMetricSink now covers exactly one TelemetrySession, so the sink instance is the session scope and the aggregation key drops back to (eventName, metricName, dimensions). It no longer registers itself either; the host does that, which is where the rest of its telemetry composition already happens. That leaves IMetricSink as the extension point for multiple sessions: compose one VSMetricSink per session behind an IMetricSink that routes between them and flushes all of them. Nothing in VSMetricSink, RoslynTelemetry, or any call site has to change for that. Also removes GetMetricSink (no callers). GetEventSinks stays: host composition uses it to preserve sinks registered via AddEventSink, and the integration test harness uses it to save and restore. LogBlockTime now guards on whether any sink wants the FunctionId instead of whether any sink exists at all, matching every other entry point. Validation: Ide.slnf and Compilers.slnf build clean; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../AbstractWorkspaceTelemetryService.cs | 2 +- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 66 ++++++------------- ...xtViewWindowVerifierInProcessExtensions.cs | 1 + .../Log/RoslynTelemetry.Workspaces.cs | 2 +- .../Core/CompilerExtensions.projitems | 1 - .../Core/Log/RoslynTelemetry.Metrics.cs | 26 +------- .../Compiler/Core/Log/TelemetrySessionKey.cs | 43 ------------ 7 files changed, 23 insertions(+), 118 deletions(-) delete mode 100644 src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs diff --git a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs index 07e356f291bc0..d359700cb932c 100644 --- a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs @@ -26,7 +26,7 @@ public void InitializeTelemetrySession(TelemetrySession telemetrySession, bool l Contract.ThrowIfFalse(CurrentSession is null); RoslynTelemetry.SetEventSinks(CreateEventSinks(telemetrySession, logDelta)); - VSMetricSink.Create(telemetrySession); + RoslynTelemetry.SetMetricSink(new VSMetricSink(telemetrySession)); FaultReporter.RegisterTelemetrySesssion(telemetrySession); CurrentSession = telemetrySession; diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index 63c464a9d2529..c4b3e10dabb35 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -16,17 +16,15 @@ namespace Microsoft.CodeAnalysis.Telemetry; /// -/// The aggregating metric sink, backed by VS Telemetry's counter and histogram APIs. +/// The aggregating metric sink for one , backed by VS Telemetry's counter +/// and histogram APIs. /// /// Measurements accumulate in memory against a VS Telemetry instrument and are posted in batches by -/// . Aggregation is keyed by in addition to the -/// instrument identity, so a process hosting more than one logical session accumulates - and posts - -/// each session's data separately. +/// , which posts everything accumulated so far and clears. /// /// -/// is global: it walks every bucket, posts each to the session that produced it, and -/// clears. Clearing on flush is what keeps a long-lived process from accruing buckets for sessions that -/// have ended. +/// A host that needs several sessions in one process composes one of these per session behind an +/// that routes between them; nothing here needs to change for that. /// /// internal sealed class VSMetricSink : IMetricSink @@ -54,13 +52,12 @@ private sealed class SessionPoster(TelemetrySession session) : IMetricPoster public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent) => session.PostMetricEvent(metricEvent); } - private readonly record struct AggregationKey(TelemetrySessionKey Session, string EventName, string MetricName, string DimensionKey); + private readonly record struct AggregationKey(string EventName, string MetricName, string DimensionKey); - private sealed class Aggregation(IInstrument instrument, TelemetryEvent telemetryEvent, IMetricPoster poster) + private sealed class Aggregation(IInstrument instrument, TelemetryEvent telemetryEvent) { public IInstrument Instrument { get; } = instrument; public TelemetryEvent TelemetryEvent { get; } = telemetryEvent; - public IMetricPoster Poster { get; } = poster; /// /// Guards this single aggregation. Held together with : @@ -77,36 +74,18 @@ private sealed class Aggregation(IInstrument instrument, TelemetryEvent telemetr private readonly object _flushLock = new(); private readonly VSTelemetryMeterProvider _meterProvider = new(); - private readonly IMetricPoster _defaultPoster; + private readonly IMetricPoster _poster; private ImmutableDictionary _aggregations = ImmutableDictionary.Empty; private ImmutableDictionary _meters = ImmutableDictionary.Empty; - private ImmutableDictionary _posters = ImmutableDictionary.Empty; - internal VSMetricSink(IMetricPoster defaultPoster) + public VSMetricSink(TelemetrySession session) + : this(new SessionPoster(session)) { - _defaultPoster = defaultPoster; - _posters = _posters.Add(TelemetrySessionKey.Default, defaultPoster); } - /// - /// Creates the sink and registers it as the process-wide metric destination. - /// - public static VSMetricSink Create(TelemetrySession session) - { - var sink = new VSMetricSink(new SessionPoster(session)); - RoslynTelemetry.SetMetricSink(sink); - return sink; - } - - /// - /// Associates a session with a key, for hosts that run more than one logical session per process. - /// - public void RegisterSession(TelemetrySessionKey key, TelemetrySession session) - { - var poster = new SessionPoster(session); - ImmutableInterlocked.AddOrUpdate(ref _posters, key, poster, (_, _) => poster); - } + internal VSMetricSink(IMetricPoster poster) + => _poster = poster; public void Count(string eventName, string metricName, long delta, ReadOnlySpan> tags) { @@ -140,9 +119,6 @@ public void Flush() foreach (var pair in aggregations) { var aggregation = pair.Value; - if (!aggregation.Poster.IsOptedIn) - continue; - // Excludes concurrent Add/Record on this instrument while the metric event is built // from it and posted. lock (aggregation.Lock) @@ -154,7 +130,7 @@ public void Flush() _ => throw ExceptionUtilities.UnexpectedValue(aggregation.Instrument), }; - aggregation.Poster.Post(aggregation.TelemetryEvent, metricEvent); + _poster.Post(aggregation.TelemetryEvent, metricEvent); } } } @@ -162,15 +138,11 @@ public void Flush() private Aggregation? GetOrCreateAggregation(string eventName, string metricName, ReadOnlySpan> tags, bool isCounter) { - var sessionKey = RoslynTelemetry.CurrentSessionKey; - if (!_posters.TryGetValue(sessionKey, out var poster)) - poster = _defaultPoster; - // Checked here so that no telemetry object graph is built for an opted-out session. - if (!poster.IsOptedIn) + if (!_poster.IsOptedIn) return null; - var key = new AggregationKey(sessionKey, eventName, metricName, BuildDimensionKey(tags)); + var key = new AggregationKey(eventName, metricName, BuildDimensionKey(tags)); if (_aggregations.TryGetValue(key, out var existing)) return existing; @@ -178,11 +150,11 @@ public void Flush() return ImmutableInterlocked.GetOrAdd( ref _aggregations, key, - static (key, arg) => arg.self.CreateAggregation(key, arg.tags, arg.isCounter, arg.poster), - (self: this, tags: tags.ToArray(), isCounter, poster)); + static (key, arg) => arg.self.CreateAggregation(key, arg.tags, arg.isCounter), + (self: this, tags: tags.ToArray(), isCounter)); } - private Aggregation CreateAggregation(AggregationKey key, KeyValuePair[] tags, bool isCounter, IMetricPoster poster) + private Aggregation CreateAggregation(AggregationKey key, KeyValuePair[] tags, bool isCounter) { var telemetryEvent = new TelemetryEvent(key.EventName); @@ -194,7 +166,7 @@ private Aggregation CreateAggregation(AggregationKey key, KeyValuePair(key.MetricName) : meter.CreateHistogram(key.MetricName); - return new Aggregation(instrument, telemetryEvent, poster); + return new Aggregation(instrument, telemetryEvent); } private IMeter GetOrCreateMeter(string eventName) diff --git a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs index af32e241103e3..60ba62442c5f9 100644 --- a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs +++ b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; diff --git a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs index 0271a900b8bab..58ff616733473 100644 --- a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs +++ b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs @@ -31,7 +31,7 @@ internal static partial class RoslynTelemetry /// its own event. /// public static IDisposable? LogBlockTime(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs = -1) - => GetEventSinks().IsEmpty ? null : new TimedEventBlock(functionId, logMessage, minThresholdMs); + => TryGetEnabledSinks(functionId, out _) ? new TimedEventBlock(functionId, logMessage, minThresholdMs) : null; private sealed class TimedEventBlock : IDisposable { diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems index 8e1d9e8a70e9a..e27a74ce6731d 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems @@ -338,7 +338,6 @@ - diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs index a18e9dc09e27e..1cb8b4837c3bf 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -12,26 +12,6 @@ internal static partial class RoslynTelemetry { private static IMetricSink? s_currentMetricSink; - private static readonly AsyncLocal t_ambientSessionKey = new(); - - /// - /// Whether consults ambient state. Only a host that actually runs - /// more than one logical session per process turns this on; leaving it off keeps the per-record - /// cost at a single static bool read. - /// - private static bool s_ambientRoutingEnabled; - - /// - /// The session that measurements recorded on this thread belong to. - /// - /// Ambient routing is not enabled, so this is always . - /// Enabling it is a matter of setting and pushing keys around - /// the work belonging to each session; no call site or sink needs to change. - /// - /// - internal static TelemetrySessionKey CurrentSessionKey - => s_ambientRoutingEnabled ? (t_ambientSessionKey.Value ?? TelemetrySessionKey.Default) : TelemetrySessionKey.Default; - /// /// Replaces the active metric sink. Hosts call this once during startup; tests reset it to /// during teardown. @@ -39,13 +19,9 @@ internal static TelemetrySessionKey CurrentSessionKey public static IMetricSink? SetMetricSink(IMetricSink? sink) => Interlocked.Exchange(ref s_currentMetricSink, sink); - public static IMetricSink? GetMetricSink() - => s_currentMetricSink; - /// /// Posts all pending aggregated measurements. Called on a timer, at shutdown, and when a logical - /// session ends. Every session's accumulated data is posted to its own session, so flushing more - /// than the caller's own session is both safe and intended. + /// session ends. /// public static void Flush() => s_currentMetricSink?.Flush(); diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs deleted file mode 100644 index cc6293565d2a8..0000000000000 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetrySessionKey.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; - -namespace Microsoft.CodeAnalysis.Internal.Log; - -/// -/// Identifies the logical telemetry session a measurement belongs to. -/// -/// A host that runs several independent language servers in one process needs each server's -/// measurements bucketed - and posted - separately, so aggregation state is keyed by this. Today there -/// is exactly one session per process and this is always . -/// -/// -/// See for how a key is resolved at record time. -/// -/// -internal readonly struct TelemetrySessionKey : IEquatable -{ - /// - /// The key used when a host has not opted into per-session routing. - /// - public static readonly TelemetrySessionKey Default = new("default"); - - public string Id { get; } - - public TelemetrySessionKey(string id) - => Id = id; - - public bool Equals(TelemetrySessionKey other) - => string.Equals(Id, other.Id, StringComparison.Ordinal); - - public override bool Equals(object? obj) - => obj is TelemetrySessionKey other && Equals(other); - - public override int GetHashCode() - => Id?.GetHashCode() ?? 0; - - public override string ToString() - => Id ?? ""; -} From 1db5b85c6765220c3dd9c5ed59e53308c01ba0b8 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 17:55:55 -0700 Subject: [PATCH 08/34] Wire the language server's metric sink; separate host and dynamic event sinks The standalone language server never got a metric sink, so every RoslynTelemetry.Count/Record call there silently no-opped -- LSP_RequestDuration, LSP_RequestCounter, LSP_TimeInQueue, LSP_FindDocumentInWorkspace and every aggregated Features/Workspaces metric stopped being reported in that host. It previously got one for free: TelemetryLogger.Create called TelemetryLogProvider.Create, which called TelemetryLogging.SetLogProvider. When that two-stage init went away only VS and OOP were rewired. That same SetLogProvider call also started the 30-minute flush loop, so the language server lost periodic flushing too. VSMetricSink now owns its own flush loop and is IDisposable, so composing a sink is all a host has to remember -- which is the failure mode that produced this bug in the first place. Hosts hold and dispose it. SetEventSinks and AddEventSink previously shared one array, so whichever ran second clobbered the first. That is a real ordering hazard, not a theoretical one: VisualStudioDiagnosticsWindowPackage.InitializeAsync calls PerformanceLoggersPage.SetLoggers, which attaches the output window sink, and nothing orders that against workspace creation. Host-composed and dynamically attached sinks are now separate lists combined on the write path, so neither can clobber the other and a host lists exactly the sinks it owns. GetEventSinks is gone from the public surface as a result; capture and restore moved to a TestAccessor, as did construction over a caller-supplied IMetricPoster. Validation: Ide.slnf and Compilers.slnf build clean; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../VSMetricSinkTests.cs | 8 +- .../LanguageServerTelemetryService.cs | 8 +- .../AbstractWorkspaceTelemetryService.cs | 26 ++---- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 46 ++++++++++- .../VisualStudioWorkspaceTelemetryService.cs | 1 - .../Services/ServiceHubServicesTests.cs | 4 +- ...xtViewWindowVerifierInProcessExtensions.cs | 8 +- .../MEF/UseExportProviderAttribute.cs | 2 +- .../RemoteWorkspaceTelemetryService.cs | 1 - .../Compiler/Core/Log/RoslynTelemetry.cs | 82 +++++++++++++++---- 10 files changed, 132 insertions(+), 54 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs index b5c361280551e..cd52007b0bd86 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs @@ -40,7 +40,7 @@ public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent public void RecordedMeasurementsArePostedExactlyOncePerFlush() { var poster = new RecordingPoster(); - var sink = new VSMetricSink(poster); + using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); sink.Count("vs/ide/vbcs/test/counter", "SucceededCount", 1, default); sink.Count("vs/ide/vbcs/test/counter", "SucceededCount", 1, default); @@ -61,7 +61,7 @@ public void RecordedMeasurementsArePostedExactlyOncePerFlush() public void TagValuesDiscriminateBuckets() { var poster = new RecordingPoster(); - var sink = new VSMetricSink(poster); + using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); // Same event and metric, different tag values: these must aggregate into separate buckets. sink.Record("vs/ide/vbcs/lsp/requestduration", "RequestDuration", 10, @@ -80,7 +80,7 @@ public void TagValuesDiscriminateBuckets() public void EventAndPropertyNamesUseTheTelemetryConvention() { var poster = new RecordingPoster(); - var sink = new VSMetricSink(poster); + using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); sink.Count("vs/ide/vbcs/lsp/requestcounter", "SucceededCount", 1, new KeyValuePair[] { new("server", "Roslyn") }); @@ -96,7 +96,7 @@ public void EventAndPropertyNamesUseTheTelemetryConvention() public void NothingIsRecordedForAnOptedOutSession() { var poster = new RecordingPoster { IsOptedIn = false }; - var sink = new VSMetricSink(poster); + using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); sink.Count("vs/ide/vbcs/test/counter", "SucceededCount", 1, default); sink.Flush(); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs index b2f177c908d4b..90ec4d4185ece 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs @@ -38,6 +38,7 @@ internal sealed class LanguageServerTelemetryService : IDisposable private readonly ServerConfiguration _serverConfiguration; private readonly ILogger _logger; private TelemetrySession? _telemetrySession; + private VSMetricSink? _metricSink; [ImportingConstructor] [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] @@ -83,7 +84,9 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD _telemetrySession = session; - RoslynTelemetry.SetEventSinks([.. RoslynTelemetry.GetEventSinks(), TelemetryLogger.Create(session, logDelta: false)]); + RoslynTelemetry.SetEventSinks([TelemetryLogger.Create(session, logDelta: false)]); + _metricSink = new VSMetricSink(session); + RoslynTelemetry.SetMetricSink(_metricSink); FaultReporter.InitializeFatalErrorHandlers(); FaultReporter.IncludeServiceHubLogFiles = false; @@ -115,6 +118,9 @@ public void Dispose() if (_telemetrySession is { } session) { RoslynTelemetry.SetEventSinks([]); + RoslynTelemetry.SetMetricSink(null); + _metricSink?.Dispose(); + _metricSink = null; FaultReporter.UnregisterTelemetrySesssion(session); session.Dispose(); diff --git a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs index d359700cb932c..8954fc8752362 100644 --- a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs @@ -19,6 +19,8 @@ internal abstract class AbstractWorkspaceTelemetryService : IWorkspaceTelemetryS { public TelemetrySession? CurrentSession { get; private set; } + private VSMetricSink? _metricSink; + protected abstract ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta); public void InitializeTelemetrySession(TelemetrySession telemetrySession, bool logDelta) @@ -26,12 +28,12 @@ public void InitializeTelemetrySession(TelemetrySession telemetrySession, bool l Contract.ThrowIfFalse(CurrentSession is null); RoslynTelemetry.SetEventSinks(CreateEventSinks(telemetrySession, logDelta)); - RoslynTelemetry.SetMetricSink(new VSMetricSink(telemetrySession)); + _metricSink = new VSMetricSink(telemetrySession); + RoslynTelemetry.SetMetricSink(_metricSink); FaultReporter.RegisterTelemetrySesssion(telemetrySession); CurrentSession = telemetrySession; - StartPeriodicFlush(); TelemetrySessionInitialized(); } @@ -60,24 +62,6 @@ public void Dispose() // Ensure any aggregate telemetry is flushed when the catalog is destroyed. // It is fine for this to be called multiple times - if telemetry has already been flushed this will no-op. RoslynTelemetry.Flush(); - } - - /// - /// Posts whatever has accumulated every 30 minutes. Shutdown paths flush explicitly as well, because - /// a host can exit too abruptly for a timer-based flush to run. - /// - private static void StartPeriodicFlush() - => _ = PostCollectedTelemetryAsync(); - - private static async Task PostCollectedTelemetryAsync() - { - await Task.Delay(TimeSpan.FromMinutes(30)).ConfigureAwait(false); - - RoslynTelemetry.Flush(); - - // Create a fire and forget task to handle the next collection. This doesn't use - // IAsynchronousOperationListener to track this work as no-one needs to ensure this is sent, and - // creating a new item of work upon previous completion doesn't fit well in that model. - _ = PostCollectedTelemetryAsync().ReportNonFatalErrorAsync(); + _metricSink?.Dispose(); } } diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index c4b3e10dabb35..dda22236b6f36 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Threading; +using System.Threading.Tasks; using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.VisualStudio.Telemetry; @@ -27,7 +28,7 @@ namespace Microsoft.CodeAnalysis.Telemetry; /// that routes between them; nothing here needs to change for that. /// /// -internal sealed class VSMetricSink : IMetricSink +internal sealed class VSMetricSink : IMetricSink, IDisposable { /// /// Version information which VS Telemetry attaches to our aggregated telemetry, so that Kusto @@ -75,6 +76,7 @@ private sealed class Aggregation(IInstrument instrument, TelemetryEvent telemetr private readonly VSTelemetryMeterProvider _meterProvider = new(); private readonly IMetricPoster _poster; + private readonly CancellationTokenSource _flushLoopCancellation = new(); private ImmutableDictionary _aggregations = ImmutableDictionary.Empty; private ImmutableDictionary _meters = ImmutableDictionary.Empty; @@ -84,8 +86,46 @@ public VSMetricSink(TelemetrySession session) { } - internal VSMetricSink(IMetricPoster poster) - => _poster = poster; + private VSMetricSink(IMetricPoster poster) + { + _poster = poster; + + // Owned here rather than by each host, so composing a sink is all a host has to remember. + // Shutdown paths flush explicitly as well, since a host can exit too abruptly for a timer. + _ = PostCollectedTelemetryAsync(); + } + + public void Dispose() + => _flushLoopCancellation.Cancel(); + + private async Task PostCollectedTelemetryAsync() + { + while (true) + { + try + { + await Task.Delay(TimeSpan.FromMinutes(30), _flushLoopCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + Flush(); + } + } + + internal static TestAccessor GetTestAccessor() => default; + + internal readonly struct TestAccessor + { + /// + /// Creates a sink over a caller-supplied poster, so a test can assert exactly how many metric + /// events a flush produces without standing up a real, opted-in + /// (which would try to send). + /// + public VSMetricSink CreateSink(IMetricPoster poster) => new(poster); + } public void Count(string eventName, string metricName, long delta, ReadOnlySpan> tags) { diff --git a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs index cecc4ff07118f..276d1275eb007 100644 --- a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs @@ -47,7 +47,6 @@ protected override ImmutableArray CreateEventSinks(TelemetrySession return [ - .. RoslynTelemetry.GetEventSinks(), CodeMarkerLogger.Instance, _etwLogger, _traceLogger, diff --git a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs index 331df7450dce1..dc2a5e3e1b8c8 100644 --- a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs +++ b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs @@ -1949,13 +1949,13 @@ void M() Assert.Equal("CSharp.ConflictMarkerResolution.CSharpResolveConflictMarkerCodeFixProvider", result.CodeFixAnalysis.DiagnosticIdToProviderName["CS8300"].Single()); var logger = new TestTelemetryLogger(); - RoslynTelemetry.SetEventSinks([logger]); + RoslynTelemetry.GetTestAccessor().SetAllEventSinks([logger]); TestTelemetryLogger.TestScope scope; using (CopilotChangeAnalysisUtilities.LogCopilotChangeAnalysis("TestCode", accepted: true, "TestProposalId", result, CancellationToken.None)) { scope = logger.OpenedScopes.Single(); } - RoslynTelemetry.SetEventSinks([]); + RoslynTelemetry.GetTestAccessor().SetAllEventSinks([]); var endEvent = scope.EndEvent; Assert.Equal("vs/ide/vbcs/copilot/analyzechange", endEvent.Name); diff --git a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs index 60ba62442c5f9..a334dbb107faa 100644 --- a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs +++ b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs @@ -101,7 +101,7 @@ public static async Task CodeActionAsync( if (!RoslynString.IsNullOrEmpty(applyFix)) { var codeActionLogger = new CodeActionLogger(); - using var loggerRestorer = WithLogger([.. RoslynTelemetry.GetEventSinks(), codeActionLogger]); + using var loggerRestorer = WithLogger([.. RoslynTelemetry.GetTestAccessor().EventSinks, codeActionLogger]); var result = await textViewWindowVerifier.TestServices.Editor.ApplyLightBulbActionAsync(applyFix, fixAllScope, blockUntilComplete, cancellationToken); @@ -157,7 +157,9 @@ await textViewWindowVerifier.TestServices.Workspace.WaitForAllAsyncOperationsAsy private static LoggerRestorer WithLogger(ImmutableArray sinks) { - return new LoggerRestorer(RoslynTelemetry.SetEventSinks(sinks)); + var previous = RoslynTelemetry.GetTestAccessor().EventSinks; + RoslynTelemetry.GetTestAccessor().SetAllEventSinks(sinks); + return new LoggerRestorer(previous); } private sealed class CodeActionLogger : IEventSink @@ -200,7 +202,7 @@ public LoggerRestorer(ImmutableArray sinks) public void Dispose() { - RoslynTelemetry.SetEventSinks(_sinks); + RoslynTelemetry.GetTestAccessor().SetAllEventSinks(_sinks); } } } diff --git a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs index c6929b2dfe5f0..f7630616035b3 100644 --- a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs +++ b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs @@ -101,7 +101,7 @@ public override void After(MethodInfo? methodUnderTest) // Reset static state variables. _hostServices = null; ExportProviderCache.SetEnabled_OnlyUseExportProviderAttributeCanCall(false); - RoslynTelemetry.SetEventSinks([]); + RoslynTelemetry.GetTestAccessor().SetAllEventSinks([]); } } diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs index dd8e8740f93b7..3ba008a9e39b4 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs @@ -31,7 +31,6 @@ protected override ImmutableArray CreateEventSinks(TelemetrySession return [ - .. RoslynTelemetry.GetEventSinks(), _etwLogger, _traceLogger, TelemetryLogger.Create(telemetrySession, logDelta), diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index f6a37e4e09ba1..f4371e05cae36 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -21,12 +21,25 @@ namespace Microsoft.CodeAnalysis.Internal.Log; internal static partial class RoslynTelemetry { /// - /// The sinks every event fans out to. Decided once, when a host composes its telemetry, and not - /// mutated afterwards: turning a sink off is that sink's own - /// returning false. Keeping the set fixed is what guarantees a sink cannot be registered twice, - /// which would post its events twice. + /// The sinks a host composes for itself. Decided once, at startup, and not mutated afterwards: + /// turning a sink off is that sink's own returning false. /// - private static ImmutableArray s_eventSinks = []; + private static ImmutableArray s_hostSinks = []; + + /// + /// Sinks attached at runtime by components the host cannot reference (the diagnostics tool window, + /// integration tests). Kept separate from so that host composition and + /// runtime attachment cannot clobber each other, whichever happens first. + /// + private static ImmutableArray s_dynamicSinks = []; + + /// + /// and combined. Maintained on the (rare) + /// write path so that recording only ever reads one array. + /// + private static ImmutableArray s_allSinks = []; + + private static readonly object s_sinkGate = new(); /// /// next unique block id that will be given to each LogBlock @@ -34,23 +47,35 @@ internal static partial class RoslynTelemetry private static int s_lastUniqueBlockId; /// - /// Replaces the active sinks. Hosts call this once during startup; tests reset it to empty during - /// teardown. Returns what was previously registered. + /// Sets the sinks this host composes. Hosts call this once during startup and pass everything they + /// want; tests reset it to empty during teardown. Sinks attached through + /// are unaffected. /// - public static ImmutableArray SetEventSinks(ImmutableArray sinks) - => ImmutableInterlocked.InterlockedExchange(ref s_eventSinks, sinks); - - public static ImmutableArray GetEventSinks() - => s_eventSinks; + public static void SetEventSinks(ImmutableArray sinks) + { + lock (s_sinkGate) + { + s_hostSinks = sinks; + s_allSinks = [.. s_hostSinks, .. s_dynamicSinks]; + } + } /// - /// Atomically adds alongside whatever is already registered, ignoring it if - /// it is already present. Used by diagnostic sinks that live in assemblies the composition root - /// cannot reference (the diagnostics tool window, integration tests). Such a sink attaches once and + /// Attaches at runtime, ignoring it if it is already present. Used by + /// diagnostic sinks that live in assemblies the host cannot reference. Such a sink attaches once and /// is thereafter controlled by its own ; it is never detached. /// public static void AddEventSink(IEventSink sink) - => ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Contains(sink) ? sinks : sinks.Add(sink), sink); + { + lock (s_sinkGate) + { + if (s_dynamicSinks.Contains(sink)) + return; + + s_dynamicSinks = s_dynamicSinks.Add(sink); + s_allSinks = [.. s_hostSinks, .. s_dynamicSinks]; + } + } /// /// Whether any registered sink wants . Checked before a @@ -59,7 +84,7 @@ public static void AddEventSink(IEventSink sink) /// private static bool TryGetEnabledSinks(FunctionId functionId, out ImmutableArray sinks) { - sinks = s_eventSinks; + sinks = s_allSinks; foreach (var sink in sinks) { @@ -78,6 +103,29 @@ private static void LogToSinks(ImmutableArray sinks, FunctionId func sink.Log(functionId, logMessage); } } + + internal static TestAccessor GetTestAccessor() => default; + + internal readonly struct TestAccessor + { + /// + /// The sinks currently recording, so a test can capture and restore them around a scenario. + /// + public ImmutableArray EventSinks => s_allSinks; + + /// + /// Replaces every sink, host-composed and dynamically attached alike. + /// + public void SetAllEventSinks(ImmutableArray sinks) + { + lock (s_sinkGate) + { + s_hostSinks = sinks; + s_dynamicSinks = []; + s_allSinks = sinks; + } + } + } /// /// log a specific event with a simple context message which should be very cheap to create /// From 16e79fc13680140a03cdfe3f36cdfa541c1104db Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 18:12:49 -0700 Subject: [PATCH 09/34] Reduce event sink registration to AddEventSink returning a registration SetEventSinks and AddEventSink wrote to one array, so whichever ran second clobbered the first. The previous commit worked around that by splitting host and dynamic sinks into separate lists. Dropping SetEventSinks removes the collision instead: a sink is added, and disposing what Add returns removes it. That is four fields, two verbs and two lifetimes down to one field and one verb. Nothing needs to distinguish "composed by the host" from "attached at runtime" any more, because neither can overwrite the other. The predicate-based AddOrReplace/Remove deleted earlier is not coming back with this: no predicate, no type matching, no nested aggregates to flatten. The caller removes exactly the sink it added. Hosts hold their registrations and dispose them on shutdown, which VS and OOP did not previously do at all. Tests scope theirs with using, so the blanket teardown in UseExportProviderAttribute is now a safety net rather than the mechanism, and the integration test harness drops its capture/restore helper entirely. Validation: Ide.slnf and Compilers.slnf build clean; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../LanguageServerTelemetryService.cs | 6 +- .../AbstractWorkspaceTelemetryService.cs | 8 +- .../Services/ServiceHubServicesTests.cs | 3 +- ...xtViewWindowVerifierInProcessExtensions.cs | 24 +----- .../Loggers/OutputWindowLogger.cs | 2 +- .../PerfMargin/PerfMarginPanel.cs | 2 +- .../MEF/UseExportProviderAttribute.cs | 2 +- .../Compiler/Core/Log/RoslynTelemetry.cs | 74 +++++-------------- 8 files changed, 33 insertions(+), 88 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs index 90ec4d4185ece..a1f299256c5e3 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs @@ -39,6 +39,7 @@ internal sealed class LanguageServerTelemetryService : IDisposable private readonly ILogger _logger; private TelemetrySession? _telemetrySession; private VSMetricSink? _metricSink; + private IDisposable? _eventSinkRegistration; [ImportingConstructor] [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] @@ -84,7 +85,7 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD _telemetrySession = session; - RoslynTelemetry.SetEventSinks([TelemetryLogger.Create(session, logDelta: false)]); + _eventSinkRegistration = RoslynTelemetry.AddEventSink(TelemetryLogger.Create(session, logDelta: false)); _metricSink = new VSMetricSink(session); RoslynTelemetry.SetMetricSink(_metricSink); @@ -117,7 +118,8 @@ public void Dispose() if (_telemetrySession is { } session) { - RoslynTelemetry.SetEventSinks([]); + _eventSinkRegistration?.Dispose(); + _eventSinkRegistration = null; RoslynTelemetry.SetMetricSink(null); _metricSink?.Dispose(); _metricSink = null; diff --git a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs index 8954fc8752362..6151bc90004a3 100644 --- a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs @@ -20,6 +20,7 @@ internal abstract class AbstractWorkspaceTelemetryService : IWorkspaceTelemetryS public TelemetrySession? CurrentSession { get; private set; } private VSMetricSink? _metricSink; + private ImmutableArray _eventSinkRegistrations = []; protected abstract ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta); @@ -27,7 +28,7 @@ public void InitializeTelemetrySession(TelemetrySession telemetrySession, bool l { Contract.ThrowIfFalse(CurrentSession is null); - RoslynTelemetry.SetEventSinks(CreateEventSinks(telemetrySession, logDelta)); + _eventSinkRegistrations = CreateEventSinks(telemetrySession, logDelta).SelectAsArray(RoslynTelemetry.AddEventSink); _metricSink = new VSMetricSink(telemetrySession); RoslynTelemetry.SetMetricSink(_metricSink); FaultReporter.RegisterTelemetrySesssion(telemetrySession); @@ -62,6 +63,11 @@ public void Dispose() // Ensure any aggregate telemetry is flushed when the catalog is destroyed. // It is fine for this to be called multiple times - if telemetry has already been flushed this will no-op. RoslynTelemetry.Flush(); + + foreach (var registration in _eventSinkRegistrations) + registration.Dispose(); + + _eventSinkRegistrations = []; _metricSink?.Dispose(); } } diff --git a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs index dc2a5e3e1b8c8..af765a8798e17 100644 --- a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs +++ b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs @@ -1949,13 +1949,12 @@ void M() Assert.Equal("CSharp.ConflictMarkerResolution.CSharpResolveConflictMarkerCodeFixProvider", result.CodeFixAnalysis.DiagnosticIdToProviderName["CS8300"].Single()); var logger = new TestTelemetryLogger(); - RoslynTelemetry.GetTestAccessor().SetAllEventSinks([logger]); + using var _ = RoslynTelemetry.AddEventSink(logger); TestTelemetryLogger.TestScope scope; using (CopilotChangeAnalysisUtilities.LogCopilotChangeAnalysis("TestCode", accepted: true, "TestProposalId", result, CancellationToken.None)) { scope = logger.OpenedScopes.Single(); } - RoslynTelemetry.GetTestAccessor().SetAllEventSinks([]); var endEvent = scope.EndEvent; Assert.Equal("vs/ide/vbcs/copilot/analyzechange", endEvent.Name); diff --git a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs index a334dbb107faa..579af07307d92 100644 --- a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs +++ b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs @@ -101,7 +101,7 @@ public static async Task CodeActionAsync( if (!RoslynString.IsNullOrEmpty(applyFix)) { var codeActionLogger = new CodeActionLogger(); - using var loggerRestorer = WithLogger([.. RoslynTelemetry.GetTestAccessor().EventSinks, codeActionLogger]); + using var loggerRegistration = RoslynTelemetry.AddEventSink(codeActionLogger); var result = await textViewWindowVerifier.TestServices.Editor.ApplyLightBulbActionAsync(applyFix, fixAllScope, blockUntilComplete, cancellationToken); @@ -155,13 +155,6 @@ await textViewWindowVerifier.TestServices.Workspace.WaitForAllAsyncOperationsAsy Assert.NotEqual("text", tokenType); } - private static LoggerRestorer WithLogger(ImmutableArray sinks) - { - var previous = RoslynTelemetry.GetTestAccessor().EventSinks; - RoslynTelemetry.GetTestAccessor().SetAllEventSinks(sinks); - return new LoggerRestorer(previous); - } - private sealed class CodeActionLogger : IEventSink { public List Messages { get; } = []; @@ -190,19 +183,4 @@ public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniq { } } - - private readonly struct LoggerRestorer : IDisposable - { - private readonly ImmutableArray _sinks; - - public LoggerRestorer(ImmutableArray sinks) - { - _sinks = sinks; - } - - public void Dispose() - { - RoslynTelemetry.GetTestAccessor().SetAllEventSinks(_sinks); - } - } } diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs index 16d1b751ff419..d01b77213f7c2 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs @@ -38,7 +38,7 @@ private OutputWindowLogger(Func isEnabledPredicate) public static void EnsureRegistered() { if (Interlocked.CompareExchange(ref s_registered, 1, 0) == 0) - RoslynTelemetry.AddEventSink(Instance); + _ = RoslynTelemetry.AddEventSink(Instance); } /// diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs index 8f6b81168b901..33b3c5f61bfe2 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs @@ -36,7 +36,7 @@ public PerfMarginPanel() // This panel lives in a separately shipped VSIX, so the composition root cannot reference this // sink. Attach it once; it is never detached. if (Interlocked.CompareExchange(ref s_registered, 1, 0) == 0) - RoslynTelemetry.AddEventSink(s_logger); + _ = RoslynTelemetry.AddEventSink(s_logger); // grid _mainGrid = new Grid(); diff --git a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs index f7630616035b3..3feba6115d342 100644 --- a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs +++ b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs @@ -101,7 +101,7 @@ public override void After(MethodInfo? methodUnderTest) // Reset static state variables. _hostServices = null; ExportProviderCache.SetEnabled_OnlyUseExportProviderAttributeCanCall(false); - RoslynTelemetry.GetTestAccessor().SetAllEventSinks([]); + RoslynTelemetry.GetTestAccessor().RemoveAllEventSinks(); } } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index f4371e05cae36..d36a0a7a67851 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -21,25 +21,11 @@ namespace Microsoft.CodeAnalysis.Internal.Log; internal static partial class RoslynTelemetry { /// - /// The sinks a host composes for itself. Decided once, at startup, and not mutated afterwards: - /// turning a sink off is that sink's own returning false. + /// The sinks every event fans out to. A sink is added once and stays until its registration is + /// disposed; turning one off without removing it is that sink's own + /// returning false. /// - private static ImmutableArray s_hostSinks = []; - - /// - /// Sinks attached at runtime by components the host cannot reference (the diagnostics tool window, - /// integration tests). Kept separate from so that host composition and - /// runtime attachment cannot clobber each other, whichever happens first. - /// - private static ImmutableArray s_dynamicSinks = []; - - /// - /// and combined. Maintained on the (rare) - /// write path so that recording only ever reads one array. - /// - private static ImmutableArray s_allSinks = []; - - private static readonly object s_sinkGate = new(); + private static ImmutableArray s_eventSinks = []; /// /// next unique block id that will be given to each LogBlock @@ -47,34 +33,20 @@ internal static partial class RoslynTelemetry private static int s_lastUniqueBlockId; /// - /// Sets the sinks this host composes. Hosts call this once during startup and pass everything they - /// want; tests reset it to empty during teardown. Sinks attached through - /// are unaffected. + /// Registers to receive events, ignoring it if it is already registered. + /// Dispose the result to unregister it; a host that keeps its sinks for the life of the process can + /// simply never dispose. /// - public static void SetEventSinks(ImmutableArray sinks) + public static IDisposable AddEventSink(IEventSink sink) { - lock (s_sinkGate) - { - s_hostSinks = sinks; - s_allSinks = [.. s_hostSinks, .. s_dynamicSinks]; - } + ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Contains(sink) ? sinks : sinks.Add(sink), sink); + return new Registration(sink); } - /// - /// Attaches at runtime, ignoring it if it is already present. Used by - /// diagnostic sinks that live in assemblies the host cannot reference. Such a sink attaches once and - /// is thereafter controlled by its own ; it is never detached. - /// - public static void AddEventSink(IEventSink sink) + private sealed class Registration(IEventSink sink) : IDisposable { - lock (s_sinkGate) - { - if (s_dynamicSinks.Contains(sink)) - return; - - s_dynamicSinks = s_dynamicSinks.Add(sink); - s_allSinks = [.. s_hostSinks, .. s_dynamicSinks]; - } + public void Dispose() + => ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Remove(sink), sink); } /// @@ -84,7 +56,7 @@ public static void AddEventSink(IEventSink sink) /// private static bool TryGetEnabledSinks(FunctionId functionId, out ImmutableArray sinks) { - sinks = s_allSinks; + sinks = s_eventSinks; foreach (var sink in sinks) { @@ -109,22 +81,10 @@ private static void LogToSinks(ImmutableArray sinks, FunctionId func internal readonly struct TestAccessor { /// - /// The sinks currently recording, so a test can capture and restore them around a scenario. + /// Unregisters every sink, so that one test cannot leak a sink into the next. /// - public ImmutableArray EventSinks => s_allSinks; - - /// - /// Replaces every sink, host-composed and dynamically attached alike. - /// - public void SetAllEventSinks(ImmutableArray sinks) - { - lock (s_sinkGate) - { - s_hostSinks = sinks; - s_dynamicSinks = []; - s_allSinks = sinks; - } - } + public void RemoveAllEventSinks() + => ImmutableInterlocked.InterlockedExchange(ref s_eventSinks, []); } /// /// log a specific event with a simple context message which should be very cheap to create From c3767fe593396480817db5f9cd781ac629708995 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 18:25:17 -0700 Subject: [PATCH 10/34] Rename LanguageServerTelemetryService; give metric sinks the same registration shape LanguageServerTelemetryService shares no type, interface or base with AbstractWorkspaceTelemetryService, and is not a workspace service in the Roslyn sense -- it owns the standalone language server's telemetry session, registers its sinks, and tears them down at shutdown. LanguageServerTelemetryHost says that without implying a relationship that does not exist. SetMetricSink was the last set-and-replace registration left. It is now AddMetricSink returning a registration, matching AddEventSink, so both signals are registered and unregistered the same way and hosts hold both handles. Measurements fan out across registered sinks exactly as events do; a host serving several sessions registers one sink that routes between them, which is the same answer for both signals rather than two different ones. There is only ever one metric sink today, so this buys consistency rather than capability -- but it removes the last place where the two signals had different lifetime rules for no reason. Validation: Ide.slnf and Compilers.slnf build clean; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../TelemetryReporterTests.cs | 12 +-- .../Razor/TelemetryReporterWrapper.cs | 6 +- .../Program.cs | 4 +- ...vice.cs => LanguageServerTelemetryHost.cs} | 12 ++- .../AbstractWorkspaceTelemetryService.cs | 6 +- .../MEF/UseExportProviderAttribute.cs | 2 +- .../Core/Log/RoslynTelemetry.Metrics.cs | 102 ++++++++++-------- .../Compiler/Core/Log/RoslynTelemetry.cs | 8 +- 8 files changed, 86 insertions(+), 66 deletions(-) rename src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/{LanguageServerTelemetryService.cs => LanguageServerTelemetryHost.cs} (94%) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs index c228826fd12b5..f75c2cade840b 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs @@ -19,12 +19,12 @@ namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests; /// public sealed class TelemetryReporterTests(ITestOutputHelper testOutputHelper) : AbstractLanguageServerHostTests(testOutputHelper) { - private LanguageServerTelemetryService CreateReporter(ServerConfiguration serverConfiguration) + private LanguageServerTelemetryHost CreateReporter(ServerConfiguration serverConfiguration) { // VS Telemetry requires this environment variable to be set. Environment.SetEnvironmentVariable("CommonPropertyBagPath", Path.GetTempFileName()); - var reporter = (LanguageServerTelemetryService?)Activator.CreateInstance(typeof(LanguageServerTelemetryService), serverConfiguration, LoggerFactory); + var reporter = (LanguageServerTelemetryHost?)Activator.CreateInstance(typeof(LanguageServerTelemetryHost), serverConfiguration, LoggerFactory); Assert.NotNull(reporter); return reporter; } @@ -54,7 +54,7 @@ public void TestRazorBridgePostsThroughTheHostSession() // The MEF importing constructor is obsolete-as-error, so construct through Activator. var wrapper = (TelemetryReporterWrapper?)Activator.CreateInstance( - typeof(TelemetryReporterWrapper), new Lazy(() => service)); + typeof(TelemetryReporterWrapper), new Lazy(() => service)); Assert.NotNull(wrapper); wrapper.ReportEvent(GetEventName(nameof(TestRazorBridgePostsThroughTheHostSession)), [new("method", "textDocument/hover")]); @@ -78,7 +78,7 @@ public void TestRazorBridgePostsThroughTheHostSession() [InlineData(null, false)] public void TestCopilotCliTelemetryLevelFailsClosed(string? telemetryLevel, bool expected) { - Assert.Equal(expected, LanguageServerTelemetryService.IsCopilotCliTelemetryEnabled(telemetryLevel)); + Assert.Equal(expected, LanguageServerTelemetryHost.IsCopilotCliTelemetryEnabled(telemetryLevel)); } [Fact] @@ -86,7 +86,7 @@ public void TestDevKitSessionPreservesVSCodeSettings() { using var currentProcess = System.Diagnostics.Process.GetCurrentProcess(); var processStartTime = currentProcess.StartTime.ToFileTimeUtc(); - var serializedSettings = LanguageServerTelemetryService.CreateDevKitSessionSettings("error", "test-session"); + var serializedSettings = LanguageServerTelemetryHost.CreateDevKitSessionSettings("error", "test-session"); var expectedSettings = $$""" {"Id":"test-session","HostName":"Default","TelemetryLevel":"error","IsInitialSession":true,"CollectorApiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","AppId":1010,"ProcessStartTime":{{processStartTime}}} """; @@ -104,7 +104,7 @@ public void TestDevKitSessionPreservesVSCodeSettings() public void TestDevKitSessionPreservesTelemetryLevelValidation() { using var session = new Microsoft.VisualStudio.Telemetry.TelemetrySession( - LanguageServerTelemetryService.CreateDevKitSessionSettings("invalid", "test-session")); + LanguageServerTelemetryHost.CreateDevKitSessionSettings("invalid", "test-session")); session.Start(); Assert.False(session.IsOptedIn); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs index 17a9b0ba70e2e..6cd4217742862 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -17,14 +17,14 @@ namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.Razor; /// /// Razor's names and properties are already final when they arrive - they do not go through Roslyn's /// FunctionId pipeline - so this posts to the session directly. It only reads the session; -/// ownership and disposal stay with . +/// ownership and disposal stay with . /// /// [Shared] [Export(typeof(ILanguageServerTelemetryReporterWrapper))] [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] -internal sealed class TelemetryReporterWrapper([Import(AllowDefault = true)] Lazy? telemetryService) : ILanguageServerTelemetryReporterWrapper +internal sealed class TelemetryReporterWrapper([Import(AllowDefault = true)] Lazy? telemetryService) : ILanguageServerTelemetryReporterWrapper { public void ReportEvent(string name, List> properties) { diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs index 7e5c73b87854f..1ed0cda73b3ec 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs @@ -128,9 +128,9 @@ static async Task RunAsync(ServerConfiguration serverConfiguration, Cancell Directory.CreateDirectory(serverConfiguration.ExtensionLogDirectory); } - var telemetryLevel = LanguageServerTelemetryService.GetTelemetryLevel(serverConfiguration); + var telemetryLevel = LanguageServerTelemetryHost.GetTelemetryLevel(serverConfiguration); var telemetryService = telemetryLevel is not null - ? exportProvider.GetExportedValue() + ? exportProvider.GetExportedValue() : null; telemetryService?.InitializeSession(telemetryLevel!, serverConfiguration.SessionId, isDefaultSession: true); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryHost.cs similarity index 94% rename from src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs rename to src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryHost.cs index a1f299256c5e3..c021255832972 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryService.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryHost.cs @@ -21,7 +21,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Telemetry; /// the event and metric sinks, and tears everything down on shutdown. The counterpart to /// AbstractWorkspaceTelemetryService in the VS and ServiceHub hosts. /// [Export, Shared] -internal sealed class LanguageServerTelemetryService : IDisposable +internal sealed class LanguageServerTelemetryHost : IDisposable { internal const string CopilotTelemetryLevelEnvironmentVariable = "COPILOT_TELEMETRY_LEVEL"; @@ -39,14 +39,15 @@ internal sealed class LanguageServerTelemetryService : IDisposable private readonly ILogger _logger; private TelemetrySession? _telemetrySession; private VSMetricSink? _metricSink; + private IDisposable? _metricSinkRegistration; private IDisposable? _eventSinkRegistration; [ImportingConstructor] [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] - public LanguageServerTelemetryService(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory) + public LanguageServerTelemetryHost(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory) { _serverConfiguration = serverConfiguration; - _logger = loggerFactory.CreateLogger(); + _logger = loggerFactory.CreateLogger(); } public void InitializeSession(string telemetryLevel, string? sessionId, bool isDefaultSession) @@ -87,7 +88,7 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD _eventSinkRegistration = RoslynTelemetry.AddEventSink(TelemetryLogger.Create(session, logDelta: false)); _metricSink = new VSMetricSink(session); - RoslynTelemetry.SetMetricSink(_metricSink); + _metricSinkRegistration = RoslynTelemetry.AddMetricSink(_metricSink); FaultReporter.InitializeFatalErrorHandlers(); FaultReporter.IncludeServiceHubLogFiles = false; @@ -120,7 +121,8 @@ public void Dispose() { _eventSinkRegistration?.Dispose(); _eventSinkRegistration = null; - RoslynTelemetry.SetMetricSink(null); + _metricSinkRegistration?.Dispose(); + _metricSinkRegistration = null; _metricSink?.Dispose(); _metricSink = null; diff --git a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs index 6151bc90004a3..f8e8ee672c8a8 100644 --- a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs @@ -20,6 +20,7 @@ internal abstract class AbstractWorkspaceTelemetryService : IWorkspaceTelemetryS public TelemetrySession? CurrentSession { get; private set; } private VSMetricSink? _metricSink; + private IDisposable? _metricSinkRegistration; private ImmutableArray _eventSinkRegistrations = []; protected abstract ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta); @@ -30,7 +31,7 @@ public void InitializeTelemetrySession(TelemetrySession telemetrySession, bool l _eventSinkRegistrations = CreateEventSinks(telemetrySession, logDelta).SelectAsArray(RoslynTelemetry.AddEventSink); _metricSink = new VSMetricSink(telemetrySession); - RoslynTelemetry.SetMetricSink(_metricSink); + _metricSinkRegistration = RoslynTelemetry.AddMetricSink(_metricSink); FaultReporter.RegisterTelemetrySesssion(telemetrySession); CurrentSession = telemetrySession; @@ -68,6 +69,9 @@ public void Dispose() registration.Dispose(); _eventSinkRegistrations = []; + + _metricSinkRegistration?.Dispose(); + _metricSinkRegistration = null; _metricSink?.Dispose(); } } diff --git a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs index 3feba6115d342..e5e3dd6707f7f 100644 --- a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs +++ b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs @@ -101,7 +101,7 @@ public override void After(MethodInfo? methodUnderTest) // Reset static state variables. _hostServices = null; ExportProviderCache.SetEnabled_OnlyUseExportProviderAttributeCanCall(false); - RoslynTelemetry.GetTestAccessor().RemoveAllEventSinks(); + RoslynTelemetry.GetTestAccessor().RemoveAllSinks(); } } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs index 1cb8b4837c3bf..a665683e77b49 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -4,61 +4,71 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.Immutable; using System.Threading; namespace Microsoft.CodeAnalysis.Internal.Log; internal static partial class RoslynTelemetry { - private static IMetricSink? s_currentMetricSink; + /// + /// The sinks every measurement fans out to. A sink is added once and stays until its registration + /// is disposed. There is one per host today; a host serving several sessions registers a sink that + /// routes between them. + /// + private static ImmutableArray s_metricSinks = []; /// - /// Replaces the active metric sink. Hosts call this once during startup; tests reset it to - /// during teardown. + /// Registers to receive measurements, ignoring it if it is already + /// registered. Dispose the result to unregister it; a host that keeps its sink for the life of the + /// process can simply never dispose. /// - public static IMetricSink? SetMetricSink(IMetricSink? sink) - => Interlocked.Exchange(ref s_currentMetricSink, sink); + public static IDisposable AddMetricSink(IMetricSink sink) + { + ImmutableInterlocked.Update(ref s_metricSinks, static (sinks, sink) => sinks.Contains(sink) ? sinks : sinks.Add(sink), sink); + return new MetricRegistration(sink); + } + + private sealed class MetricRegistration(IMetricSink sink) : IDisposable + { + public void Dispose() + => ImmutableInterlocked.Update(ref s_metricSinks, static (sinks, sink) => sinks.Remove(sink), sink); + } /// /// Posts all pending aggregated measurements. Called on a timer, at shutdown, and when a logical /// session ends. /// public static void Flush() - => s_currentMetricSink?.Flush(); + { + foreach (var sink in s_metricSinks) + sink.Flush(); + } #region Counters public static void Count(FunctionId functionId, string metricName, long delta = 1) { - if (s_currentMetricSink is { } sink) - sink.Count(TelemetryNaming.GetEventName(functionId), metricName, delta, default); + CountCore(functionId, metricName, delta, default); } public static void Count(FunctionId functionId, string metricName, long delta, KeyValuePair tag) { - if (s_currentMetricSink is { } sink) - { - Span> tags = [tag]; - sink.Count(TelemetryNaming.GetEventName(functionId), metricName, delta, tags); - } + Span> tags = [tag]; + CountCore(functionId, metricName, delta, tags); } public static void Count(FunctionId functionId, string metricName, long delta, KeyValuePair tag1, KeyValuePair tag2) { - if (s_currentMetricSink is { } sink) - { - Span> tags = [tag1, tag2]; - sink.Count(TelemetryNaming.GetEventName(functionId), metricName, delta, tags); - } + Span> tags = [tag1, tag2]; + CountCore(functionId, metricName, delta, tags); } public static void Count(FunctionId functionId, string metricName, long delta, KeyValuePair tag1, KeyValuePair tag2, KeyValuePair tag3) { - if (s_currentMetricSink is { } sink) - { - Span> tags = [tag1, tag2, tag3]; - sink.Count(TelemetryNaming.GetEventName(functionId), metricName, delta, tags); - } + Span> tags = [tag1, tag2, tag3]; + CountCore(functionId, metricName, delta, tags); } /// @@ -67,8 +77,13 @@ public static void Count(FunctionId functionId, string metricName, long delta, K /// private static void CountCore(FunctionId functionId, string metricName, long delta, ReadOnlySpan> tags) { - if (s_currentMetricSink is { } sink) - sink.Count(TelemetryNaming.GetEventName(functionId), metricName, delta, tags); + var sinks = s_metricSinks; + if (sinks.IsEmpty) + return; + + var eventName = TelemetryNaming.GetEventName(functionId); + foreach (var sink in sinks) + sink.Count(eventName, metricName, delta, tags); } #endregion @@ -77,41 +92,36 @@ private static void CountCore(FunctionId functionId, string metricName, long del public static void Record(FunctionId functionId, string metricName, long value) { - if (s_currentMetricSink is { } sink) - sink.Record(TelemetryNaming.GetEventName(functionId), metricName, value, default); + RecordCore(functionId, metricName, value, default); } public static void Record(FunctionId functionId, string metricName, long value, KeyValuePair tag) { - if (s_currentMetricSink is { } sink) - { - Span> tags = [tag]; - sink.Record(TelemetryNaming.GetEventName(functionId), metricName, value, tags); - } + Span> tags = [tag]; + RecordCore(functionId, metricName, value, tags); } public static void Record(FunctionId functionId, string metricName, long value, KeyValuePair tag1, KeyValuePair tag2) { - if (s_currentMetricSink is { } sink) - { - Span> tags = [tag1, tag2]; - sink.Record(TelemetryNaming.GetEventName(functionId), metricName, value, tags); - } + Span> tags = [tag1, tag2]; + RecordCore(functionId, metricName, value, tags); } public static void Record(FunctionId functionId, string metricName, long value, KeyValuePair tag1, KeyValuePair tag2, KeyValuePair tag3) { - if (s_currentMetricSink is { } sink) - { - Span> tags = [tag1, tag2, tag3]; - sink.Record(TelemetryNaming.GetEventName(functionId), metricName, value, tags); - } + Span> tags = [tag1, tag2, tag3]; + RecordCore(functionId, metricName, value, tags); } private static void RecordCore(FunctionId functionId, string metricName, long value, ReadOnlySpan> tags) { - if (s_currentMetricSink is { } sink) - sink.Record(TelemetryNaming.GetEventName(functionId), metricName, value, tags); + var sinks = s_metricSinks; + if (sinks.IsEmpty) + return; + + var eventName = TelemetryNaming.GetEventName(functionId); + foreach (var sink in sinks) + sink.Record(eventName, metricName, value, tags); } #endregion @@ -122,11 +132,11 @@ private static void RecordCore(FunctionId functionId, string metricName, long va /// is configured, so callers can using the result unconditionally. /// public static IDisposable? RecordBlockTime(FunctionId functionId, string metricName, int minThresholdMs = -1) - => s_currentMetricSink is null ? null : new TimedBlock(functionId, metricName, minThresholdMs, default); + => s_metricSinks.IsEmpty ? null : new TimedBlock(functionId, metricName, minThresholdMs, default); /// public static IDisposable? RecordBlockTime(FunctionId functionId, string metricName, int minThresholdMs, params KeyValuePair[] tags) - => s_currentMetricSink is null ? null : new TimedBlock(functionId, metricName, minThresholdMs, tags); + => s_metricSinks.IsEmpty ? null : new TimedBlock(functionId, metricName, minThresholdMs, tags); private sealed class TimedBlock : IDisposable { diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index d36a0a7a67851..e02783fc1937c 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -83,9 +83,13 @@ internal readonly struct TestAccessor /// /// Unregisters every sink, so that one test cannot leak a sink into the next. /// - public void RemoveAllEventSinks() - => ImmutableInterlocked.InterlockedExchange(ref s_eventSinks, []); + public void RemoveAllSinks() + { + ImmutableInterlocked.InterlockedExchange(ref s_eventSinks, []); + ImmutableInterlocked.InterlockedExchange(ref s_metricSinks, []); + } } + /// /// log a specific event with a simple context message which should be very cheap to create /// From 23513fc899d434c4a4b07cd651db6f5aba835d19 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 18:33:41 -0700 Subject: [PATCH 11/34] Collapse each host's telemetry teardown to one disposables array Both hosts tracked a metric sink, its registration, and their event sink registrations in separate fields, then unwound them one at a time. VSMetricSink is itself IDisposable, so all of it fits one ImmutableArray ordered the way teardown has to run: registrations first, then the sinks they pointed at. RoslynTelemetry likewise had two near-identical registration types. Both now use one Registration that takes the unregister action, since the only thing that differed was which array to remove from. Also renames LanguageServerTelemetryHost to LanguageServerTelemetry -- the namespace already says LanguageServer, and "Host" collides with LanguageServerHost meaning the server itself. Validation: Ide.slnf and Compilers.slnf build clean; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../TelemetryReporterTests.cs | 12 +++---- .../Razor/TelemetryReporterWrapper.cs | 4 +-- .../Program.cs | 4 +-- ...etryHost.cs => LanguageServerTelemetry.cs} | 36 +++++++++++-------- .../AbstractWorkspaceTelemetryService.cs | 27 +++++++------- .../Core/Log/RoslynTelemetry.Metrics.cs | 8 +---- .../Compiler/Core/Log/RoslynTelemetry.cs | 10 +++--- 7 files changed, 53 insertions(+), 48 deletions(-) rename src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/{LanguageServerTelemetryHost.cs => LanguageServerTelemetry.cs} (88%) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs index f75c2cade840b..2a34550411585 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs @@ -19,12 +19,12 @@ namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests; /// public sealed class TelemetryReporterTests(ITestOutputHelper testOutputHelper) : AbstractLanguageServerHostTests(testOutputHelper) { - private LanguageServerTelemetryHost CreateReporter(ServerConfiguration serverConfiguration) + private LanguageServerTelemetry CreateReporter(ServerConfiguration serverConfiguration) { // VS Telemetry requires this environment variable to be set. Environment.SetEnvironmentVariable("CommonPropertyBagPath", Path.GetTempFileName()); - var reporter = (LanguageServerTelemetryHost?)Activator.CreateInstance(typeof(LanguageServerTelemetryHost), serverConfiguration, LoggerFactory); + var reporter = (LanguageServerTelemetry?)Activator.CreateInstance(typeof(LanguageServerTelemetry), serverConfiguration, LoggerFactory); Assert.NotNull(reporter); return reporter; } @@ -54,7 +54,7 @@ public void TestRazorBridgePostsThroughTheHostSession() // The MEF importing constructor is obsolete-as-error, so construct through Activator. var wrapper = (TelemetryReporterWrapper?)Activator.CreateInstance( - typeof(TelemetryReporterWrapper), new Lazy(() => service)); + typeof(TelemetryReporterWrapper), new Lazy(() => service)); Assert.NotNull(wrapper); wrapper.ReportEvent(GetEventName(nameof(TestRazorBridgePostsThroughTheHostSession)), [new("method", "textDocument/hover")]); @@ -78,7 +78,7 @@ public void TestRazorBridgePostsThroughTheHostSession() [InlineData(null, false)] public void TestCopilotCliTelemetryLevelFailsClosed(string? telemetryLevel, bool expected) { - Assert.Equal(expected, LanguageServerTelemetryHost.IsCopilotCliTelemetryEnabled(telemetryLevel)); + Assert.Equal(expected, LanguageServerTelemetry.IsCopilotCliTelemetryEnabled(telemetryLevel)); } [Fact] @@ -86,7 +86,7 @@ public void TestDevKitSessionPreservesVSCodeSettings() { using var currentProcess = System.Diagnostics.Process.GetCurrentProcess(); var processStartTime = currentProcess.StartTime.ToFileTimeUtc(); - var serializedSettings = LanguageServerTelemetryHost.CreateDevKitSessionSettings("error", "test-session"); + var serializedSettings = LanguageServerTelemetry.CreateDevKitSessionSettings("error", "test-session"); var expectedSettings = $$""" {"Id":"test-session","HostName":"Default","TelemetryLevel":"error","IsInitialSession":true,"CollectorApiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","AppId":1010,"ProcessStartTime":{{processStartTime}}} """; @@ -104,7 +104,7 @@ public void TestDevKitSessionPreservesVSCodeSettings() public void TestDevKitSessionPreservesTelemetryLevelValidation() { using var session = new Microsoft.VisualStudio.Telemetry.TelemetrySession( - LanguageServerTelemetryHost.CreateDevKitSessionSettings("invalid", "test-session")); + LanguageServerTelemetry.CreateDevKitSessionSettings("invalid", "test-session")); session.Start(); Assert.False(session.IsOptedIn); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs index 6cd4217742862..f3b01ba2bd1c6 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs @@ -17,14 +17,14 @@ namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.Razor; /// /// Razor's names and properties are already final when they arrive - they do not go through Roslyn's /// FunctionId pipeline - so this posts to the session directly. It only reads the session; -/// ownership and disposal stay with . +/// ownership and disposal stay with . /// /// [Shared] [Export(typeof(ILanguageServerTelemetryReporterWrapper))] [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] -internal sealed class TelemetryReporterWrapper([Import(AllowDefault = true)] Lazy? telemetryService) : ILanguageServerTelemetryReporterWrapper +internal sealed class TelemetryReporterWrapper([Import(AllowDefault = true)] Lazy? telemetryService) : ILanguageServerTelemetryReporterWrapper { public void ReportEvent(string name, List> properties) { diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs index 1ed0cda73b3ec..d3eeabb9dc357 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs @@ -128,9 +128,9 @@ static async Task RunAsync(ServerConfiguration serverConfiguration, Cancell Directory.CreateDirectory(serverConfiguration.ExtensionLogDirectory); } - var telemetryLevel = LanguageServerTelemetryHost.GetTelemetryLevel(serverConfiguration); + var telemetryLevel = LanguageServerTelemetry.GetTelemetryLevel(serverConfiguration); var telemetryService = telemetryLevel is not null - ? exportProvider.GetExportedValue() + ? exportProvider.GetExportedValue() : null; telemetryService?.InitializeSession(telemetryLevel!, serverConfiguration.SessionId, isDefaultSession: true); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryHost.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs similarity index 88% rename from src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryHost.cs rename to src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs index c021255832972..79143056ea3ad 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetryHost.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Immutable; using System.Composition; using System.Diagnostics; using System.Text; @@ -21,7 +22,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Telemetry; /// the event and metric sinks, and tears everything down on shutdown. The counterpart to /// AbstractWorkspaceTelemetryService in the VS and ServiceHub hosts. /// [Export, Shared] -internal sealed class LanguageServerTelemetryHost : IDisposable +internal sealed class LanguageServerTelemetry : IDisposable { internal const string CopilotTelemetryLevelEnvironmentVariable = "COPILOT_TELEMETRY_LEVEL"; @@ -38,16 +39,19 @@ internal sealed class LanguageServerTelemetryHost : IDisposable private readonly ServerConfiguration _serverConfiguration; private readonly ILogger _logger; private TelemetrySession? _telemetrySession; - private VSMetricSink? _metricSink; - private IDisposable? _metricSinkRegistration; - private IDisposable? _eventSinkRegistration; + + /// + /// Everything this type registered or owns, in the order it must be torn down: sink registrations + /// first, then the sinks themselves. + /// + private ImmutableArray _registrations = []; [ImportingConstructor] [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] - public LanguageServerTelemetryHost(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory) + public LanguageServerTelemetry(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory) { _serverConfiguration = serverConfiguration; - _logger = loggerFactory.CreateLogger(); + _logger = loggerFactory.CreateLogger(); } public void InitializeSession(string telemetryLevel, string? sessionId, bool isDefaultSession) @@ -86,9 +90,13 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD _telemetrySession = session; - _eventSinkRegistration = RoslynTelemetry.AddEventSink(TelemetryLogger.Create(session, logDelta: false)); - _metricSink = new VSMetricSink(session); - _metricSinkRegistration = RoslynTelemetry.AddMetricSink(_metricSink); + var metricSink = new VSMetricSink(session); + _registrations = + [ + RoslynTelemetry.AddEventSink(TelemetryLogger.Create(session, logDelta: false)), + RoslynTelemetry.AddMetricSink(metricSink), + metricSink, + ]; FaultReporter.InitializeFatalErrorHandlers(); FaultReporter.IncludeServiceHubLogFiles = false; @@ -119,12 +127,10 @@ public void Dispose() if (_telemetrySession is { } session) { - _eventSinkRegistration?.Dispose(); - _eventSinkRegistration = null; - _metricSinkRegistration?.Dispose(); - _metricSinkRegistration = null; - _metricSink?.Dispose(); - _metricSink = null; + foreach (var registration in _registrations) + registration.Dispose(); + + _registrations = []; FaultReporter.UnregisterTelemetrySesssion(session); session.Dispose(); diff --git a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs index f8e8ee672c8a8..95cd1f86b6af9 100644 --- a/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/AbstractWorkspaceTelemetryService.cs @@ -19,9 +19,11 @@ internal abstract class AbstractWorkspaceTelemetryService : IWorkspaceTelemetryS { public TelemetrySession? CurrentSession { get; private set; } - private VSMetricSink? _metricSink; - private IDisposable? _metricSinkRegistration; - private ImmutableArray _eventSinkRegistrations = []; + /// + /// Everything this service registered or owns, in the order it must be torn down: sink + /// registrations first, then the sinks themselves. + /// + private ImmutableArray _registrations = []; protected abstract ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta); @@ -29,9 +31,14 @@ public void InitializeTelemetrySession(TelemetrySession telemetrySession, bool l { Contract.ThrowIfFalse(CurrentSession is null); - _eventSinkRegistrations = CreateEventSinks(telemetrySession, logDelta).SelectAsArray(RoslynTelemetry.AddEventSink); - _metricSink = new VSMetricSink(telemetrySession); - _metricSinkRegistration = RoslynTelemetry.AddMetricSink(_metricSink); + var metricSink = new VSMetricSink(telemetrySession); + _registrations = + [ + .. CreateEventSinks(telemetrySession, logDelta).SelectAsArray(RoslynTelemetry.AddEventSink), + RoslynTelemetry.AddMetricSink(metricSink), + metricSink, + ]; + FaultReporter.RegisterTelemetrySesssion(telemetrySession); CurrentSession = telemetrySession; @@ -65,13 +72,9 @@ public void Dispose() // It is fine for this to be called multiple times - if telemetry has already been flushed this will no-op. RoslynTelemetry.Flush(); - foreach (var registration in _eventSinkRegistrations) + foreach (var registration in _registrations) registration.Dispose(); - _eventSinkRegistrations = []; - - _metricSinkRegistration?.Dispose(); - _metricSinkRegistration = null; - _metricSink?.Dispose(); + _registrations = []; } } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs index a665683e77b49..382e9616c53b5 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -27,13 +27,7 @@ internal static partial class RoslynTelemetry public static IDisposable AddMetricSink(IMetricSink sink) { ImmutableInterlocked.Update(ref s_metricSinks, static (sinks, sink) => sinks.Contains(sink) ? sinks : sinks.Add(sink), sink); - return new MetricRegistration(sink); - } - - private sealed class MetricRegistration(IMetricSink sink) : IDisposable - { - public void Dispose() - => ImmutableInterlocked.Update(ref s_metricSinks, static (sinks, sink) => sinks.Remove(sink), sink); + return new Registration(() => ImmutableInterlocked.Update(ref s_metricSinks, static (sinks, sink) => sinks.Remove(sink), sink)); } /// diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index e02783fc1937c..3ccc4afeb81b1 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -40,13 +40,15 @@ internal static partial class RoslynTelemetry public static IDisposable AddEventSink(IEventSink sink) { ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Contains(sink) ? sinks : sinks.Add(sink), sink); - return new Registration(sink); + return new Registration(() => ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Remove(sink), sink)); } - private sealed class Registration(IEventSink sink) : IDisposable + /// + /// Undoes one or call. + /// + private sealed class Registration(Action unregister) : IDisposable { - public void Dispose() - => ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Remove(sink), sink); + public void Dispose() => unregister(); } /// From bf1f14099be28a922df1232189375ef2ee4c57b0 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 18:42:51 -0700 Subject: [PATCH 12/34] Register the trace and output window sinks only while they are enabled Both exist solely for the Performance Loggers options page, so they no longer need a mutable predicate or a place in any host's composition: the page registers one when the box is checked and disposes the registration when it is not. TraceLogger goes back to an immutable predicate, OutputWindowLogger loses its singleton and EnsureRegistered, and both hosts drop their _traceLogger field. EtwLogger stays composed. Roslyn.VisualStudio.DiagnosticsWindow is IsShipping=false, so making ETW depend on the options page would mean enabling ETW for a single FunctionId on a customer machine -- the documented vsregedit Roslyn\Internal\Performance\FunctionId workflow -- would require an internal VSIX. Its predicate is a snapshot from FunctionIdOptions.CreateFunctionIsEnabledPredicate rather than a live read, so it still needs refreshing when options change; UpdateDiagnosticSinkEnablement narrows to UpdateEtwEnablement. So there are two mechanisms, but now for a stated reason rather than by accident: composed for what must work without the diagnostics VSIX, registered on demand for what cannot exist without it. Validation: Ide.slnf and Compilers.slnf build clean; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../VisualStudioWorkspaceTelemetryService.cs | 17 ++++--------- .../Loggers/OutputWindowLogger.cs | 24 +++---------------- .../OptionPages/PerformanceLoggersPage.cs | 23 ++++++++++++------ .../Core/Portable/Log/TraceLogger.cs | 19 ++++----------- .../RemoteProcessTelemetryService.cs | 11 +++++---- .../RemoteWorkspaceTelemetryService.cs | 16 ++++--------- 6 files changed, 40 insertions(+), 70 deletions(-) diff --git a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs index 276d1275eb007..f1797173596e9 100644 --- a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs @@ -33,23 +33,19 @@ internal sealed class VisualStudioWorkspaceTelemetryService( private readonly IGlobalOptionService _globalOptions = globalOptions; /// - /// Opt-in diagnostic sinks. Composed once, at startup, and thereafter enabled or disabled through - /// their own predicates by the Performance Loggers options page. Keeping them in the composed list - /// is what guarantees each is registered exactly once. + /// Composed once, at startup, so that ETW works without the (non-shipping) diagnostics VSIX. Its + /// predicate is a snapshot of the per-FunctionId options, refreshed by the Performance Loggers page. /// private EtwLogger? _etwLogger; - private TraceLogger? _traceLogger; protected override ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta) { _etwLogger = new EtwLogger(FunctionIdOptions.CreateFunctionIsEnabledPredicate(_globalOptions)); - _traceLogger = new TraceLogger(EtwLogger.DisabledPredicate); return [ CodeMarkerLogger.Instance, _etwLogger, - _traceLogger, RoslynActivityLogger.Sink, TelemetryLogger.Create(telemetrySession, logDelta), new FileLogger(_globalOptions, _threadingContext), @@ -57,13 +53,10 @@ protected override ImmutableArray CreateEventSinks(TelemetrySession } /// - /// Refreshes the enablement of the composed opt-in sinks, for the Performance Loggers options page. + /// Refreshes the composed ETW sink's enablement, for the Performance Loggers options page. /// - internal void UpdateDiagnosticSinkEnablement(bool etwEnabled, bool traceEnabled, Func isEnabled) - { - _etwLogger?.UpdatePredicate(etwEnabled ? isEnabled : EtwLogger.DisabledPredicate); - _traceLogger?.UpdatePredicate(traceEnabled ? isEnabled : EtwLogger.DisabledPredicate); - } + internal void UpdateEtwEnablement(bool enabled, Func isEnabled) + => _etwLogger?.UpdatePredicate(enabled ? isEnabled : EtwLogger.DisabledPredicate); protected override void TelemetrySessionInitialized() { diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs index d01b77213f7c2..9938e092a9b5a 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs @@ -20,33 +20,15 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// internal sealed class OutputWindowLogger : IEventSink { - /// - /// Lives in the diagnostics tool window VSIX, which the telemetry composition root cannot reference, - /// so this attaches itself once and is thereafter controlled purely by its predicate. - /// - public static readonly OutputWindowLogger Instance = new(EtwLogger.DisabledPredicate); + private readonly Func _isEnabledPredicate; - private static int s_registered; - - private Func _isEnabledPredicate; - - private OutputWindowLogger(Func isEnabledPredicate) + public OutputWindowLogger(Func isEnabledPredicate) { _isEnabledPredicate = isEnabledPredicate; } - public static void EnsureRegistered() - { - if (Interlocked.CompareExchange(ref s_registered, 1, 0) == 0) - _ = RoslynTelemetry.AddEventSink(Instance); - } - - /// - public void UpdatePredicate(Func isEnabledPredicate) - => Volatile.Write(ref _isEnabledPredicate, isEnabledPredicate); - public bool IsEnabled(FunctionId functionId) - => Volatile.Read(ref _isEnabledPredicate)(functionId); + => _isEnabledPredicate(functionId); public void Log(FunctionId functionId, LogMessage logMessage) { diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs index d02ab7ad8c31d..d9917d5138af7 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs @@ -31,6 +31,9 @@ internal sealed class PerformanceLoggersPage : AbstractOptionPage private IThreadingContext _threadingContext; private SolutionServices _workspaceServices; + private static IDisposable? s_traceRegistration; + private static IDisposable? s_outputWindowRegistration; + protected override AbstractOptionPageControl CreateOptionPage(IServiceProvider serviceProvider, OptionStore optionStore) { if (_globalOptions == null) @@ -62,15 +65,16 @@ public static void SetLoggers(IGlobalOptionService globalOptions, IThreadingCont var traceEnabled = globalOptions.GetOption(LoggerOptionsStorage.TraceLoggerKey); var outputWindowEnabled = globalOptions.GetOption(LoggerOptionsStorage.OutputWindowLoggerKey); - // ETW and Trace sinks are part of VS's default composition, so refresh those instances. Two - // registered EtwLoggers would post every event twice. + // The ETW sink is part of VS's shipping composition, because enabling ETW for one FunctionId on + // a customer machine must not require this (non-shipping) VSIX. Its predicate is a snapshot of + // the per-FunctionId options, so it is refreshed rather than replaced. var telemetryService = workspaceServices.GetService() as VisualStudioWorkspaceTelemetryService; - telemetryService?.UpdateDiagnosticSinkEnablement(etwEnabled, traceEnabled, isEnabled); + telemetryService?.UpdateEtwEnablement(etwEnabled, isEnabled); - // The output window sink lives in this (separately shipped) VSIX, so the composition root cannot - // reference it. Attach it once, then control it purely through its predicate. - OutputWindowLogger.EnsureRegistered(); - OutputWindowLogger.Instance.UpdatePredicate(outputWindowEnabled ? isEnabled : EtwLogger.DisabledPredicate); + // These two exist only for this page, so they are registered while enabled and unregistered + // when not. + Register(ref s_traceRegistration, traceEnabled, () => new TraceLogger(isEnabled)); + Register(ref s_outputWindowRegistration, outputWindowEnabled, () => new OutputWindowLogger(isEnabled)); // update loggers in remote process var client = threadingContext.JoinableTaskFactory.Run(() => RemoteHostClient.TryGetClientAsync(workspaceServices, CancellationToken.None)); @@ -88,5 +92,10 @@ public static void SetLoggers(IGlobalOptionService globalOptions, IThreadingCont (service, cancellationToken) => service.EnableLoggingAsync(loggerTypeNames, functionIds, cancellationToken), CancellationToken.None).ConfigureAwait(false)); } + + static void Register(ref IDisposable? registration, bool enabled, Func create) + { + Interlocked.Exchange(ref registration, enabled ? RoslynTelemetry.AddEventSink(create()) : null)?.Dispose(); + } } } diff --git a/src/Workspaces/Core/Portable/Log/TraceLogger.cs b/src/Workspaces/Core/Portable/Log/TraceLogger.cs index 77359ce3a765d..d2ebea6bc61d4 100644 --- a/src/Workspaces/Core/Portable/Log/TraceLogger.cs +++ b/src/Workspaces/Core/Portable/Log/TraceLogger.cs @@ -9,22 +9,13 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// Implementation of that produces timing debug output. Opt-in, and controlled -/// the same way as : it stays registered and its predicate decides whether -/// anything is written. +/// Implementation of that produces timing debug output. Opt-in: registered by +/// the Performance Loggers options page while enabled, and unregistered when not. /// -internal sealed class TraceLogger : IEventSink{ - private Func _isEnabledPredicate; - - public TraceLogger(Func isEnabledPredicate) - => _isEnabledPredicate = isEnabledPredicate; - - /// - public void UpdatePredicate(Func isEnabledPredicate) - => Volatile.Write(ref _isEnabledPredicate, isEnabledPredicate); - +internal sealed class TraceLogger(Func isEnabledPredicate) : IEventSink +{ public bool IsEnabled(FunctionId functionId) - => Volatile.Read(ref _isEnabledPredicate)(functionId); + => isEnabledPredicate(functionId); public void Log(FunctionId functionId, LogMessage logMessage) => Trace.WriteLine(string.Format("[{0}] {1} - {2}", Environment.CurrentManagedThreadId, functionId.ToString(), logMessage.GetMessage())); diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs index cb94eaa138d59..fa02560a8e0b0 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs @@ -30,6 +30,8 @@ protected override IRemoteProcessTelemetryService CreateService(in ServiceConstr private PerformanceReporter? _performanceReporter; + private static IDisposable? s_traceRegistration; + public override void Dispose() { _performanceReporter?.Dispose(); @@ -80,12 +82,11 @@ public ValueTask EnableLoggingAsync(ImmutableArray loggerTypeNames, Immu var functionIdsSet = new HashSet(functionIds); bool logChecker(FunctionId id) => functionIdsSet.Contains(id); - // The sinks are composed once at startup; only their enablement changes here. var telemetryService = (RemoteWorkspaceTelemetryService)GetWorkspace().Services.GetRequiredService(); - telemetryService.UpdateDiagnosticSinkEnablement( - etwEnabled: loggerTypeNames.Contains(nameof(EtwLogger)), - traceEnabled: loggerTypeNames.Contains(nameof(TraceLogger)), - logChecker); + telemetryService.UpdateEtwEnablement(loggerTypeNames.Contains(nameof(EtwLogger)), logChecker); + + var traceEnabled = loggerTypeNames.Contains(nameof(TraceLogger)); + Interlocked.Exchange(ref s_traceRegistration, traceEnabled ? RoslynTelemetry.AddEventSink(new TraceLogger(logChecker)) : null)?.Dispose(); }, cancellationToken); } } diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs index 3ba008a9e39b4..f17c9449f4267 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs @@ -18,29 +18,23 @@ namespace Microsoft.VisualStudio.LanguageServices.Telemetry; internal sealed class RemoteWorkspaceTelemetryService() : AbstractWorkspaceTelemetryService { /// - /// Opt-in diagnostic sinks. Composed once, at startup, and thereafter toggled through their - /// predicates by IRemoteProcessTelemetryService.EnableLoggingAsync. + /// Composed once, at startup, mirroring the VS host. Toggled through its predicate by + /// IRemoteProcessTelemetryService.EnableLoggingAsync. /// private EtwLogger? _etwLogger; - private TraceLogger? _traceLogger; protected override ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta) { _etwLogger = new EtwLogger(EtwLogger.DisabledPredicate); - _traceLogger = new TraceLogger(EtwLogger.DisabledPredicate); return [ _etwLogger, - _traceLogger, TelemetryLogger.Create(telemetrySession, logDelta), ]; } - /// - internal void UpdateDiagnosticSinkEnablement(bool etwEnabled, bool traceEnabled, Func isEnabled) - { - _etwLogger?.UpdatePredicate(etwEnabled ? isEnabled : EtwLogger.DisabledPredicate); - _traceLogger?.UpdatePredicate(traceEnabled ? isEnabled : EtwLogger.DisabledPredicate); - } + /// + internal void UpdateEtwEnablement(bool enabled, Func isEnabled) + => _etwLogger?.UpdatePredicate(enabled ? isEnabled : EtwLogger.DisabledPredicate); } From 34e618ada4e550078007838633a3e7596b1e82d9 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 18:52:35 -0700 Subject: [PATCH 13/34] Keep the ETW sink dynamic in the OOP process RemoteWorkspaceTelemetryService never composed an ETW sink: in ServiceHub, ETW only ever existed because the Performance Loggers page pushed it over IRemoteProcessTelemetryService.EnableLoggingAsync. Composing a permanently disabled one there and toggling its predicate added a field, a method and an always-false sink for nothing -- the reason ETW stays composed in devenv (the vsregedit per-FunctionId workflow must work without the non-shipping diagnostics VSIX) does not apply to a process that can only be reached from that VSIX in the first place. So EnableLoggingAsync now registers ETW next to the trace sink and disposes the registration when unchecked, and RemoteWorkspaceTelemetryService is back to composing just the telemetry sink. Also drop the PerfMarginPanel comment about the composition root and state instead why the registration is guarded and never disposed: the tool window can be reopened over the same static model. Validation: Ide.slnf builds clean; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../PerfMargin/PerfMarginPanel.cs | 5 +++-- .../RemoteProcessTelemetryService.cs | 11 +++++----- .../RemoteWorkspaceTelemetryService.cs | 20 +------------------ 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs index 33b3c5f61bfe2..f779b5370b69d 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs @@ -33,8 +33,9 @@ public sealed class PerfMarginPanel : UserControl public PerfMarginPanel() { - // This panel lives in a separately shipped VSIX, so the composition root cannot reference this - // sink. Attach it once; it is never detached. + // The tool window can be closed and reopened, constructing another panel over the same static + // model. Attach the sink on the first construction only, and leave it attached so the model + // keeps accumulating while the window is closed. if (Interlocked.CompareExchange(ref s_registered, 1, 0) == 0) _ = RoslynTelemetry.AddEventSink(s_logger); diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs index fa02560a8e0b0..a15f926aa3536 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs @@ -30,6 +30,7 @@ protected override IRemoteProcessTelemetryService CreateService(in ServiceConstr private PerformanceReporter? _performanceReporter; + private static IDisposable? s_etwRegistration; private static IDisposable? s_traceRegistration; public override void Dispose() @@ -82,11 +83,11 @@ public ValueTask EnableLoggingAsync(ImmutableArray loggerTypeNames, Immu var functionIdsSet = new HashSet(functionIds); bool logChecker(FunctionId id) => functionIdsSet.Contains(id); - var telemetryService = (RemoteWorkspaceTelemetryService)GetWorkspace().Services.GetRequiredService(); - telemetryService.UpdateEtwEnablement(loggerTypeNames.Contains(nameof(EtwLogger)), logChecker); - - var traceEnabled = loggerTypeNames.Contains(nameof(TraceLogger)); - Interlocked.Exchange(ref s_traceRegistration, traceEnabled ? RoslynTelemetry.AddEventSink(new TraceLogger(logChecker)) : null)?.Dispose(); + Register(ref s_etwRegistration, loggerTypeNames.Contains(nameof(EtwLogger)), () => new EtwLogger(logChecker)); + Register(ref s_traceRegistration, loggerTypeNames.Contains(nameof(TraceLogger)), () => new TraceLogger(logChecker)); }, cancellationToken); + + static void Register(ref IDisposable? registration, bool enabled, Func create) + => Interlocked.Exchange(ref registration, enabled ? RoslynTelemetry.AddEventSink(create()) : null)?.Dispose(); } } diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs index f17c9449f4267..225e007fbdfb7 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs @@ -17,24 +17,6 @@ namespace Microsoft.VisualStudio.LanguageServices.Telemetry; [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] internal sealed class RemoteWorkspaceTelemetryService() : AbstractWorkspaceTelemetryService { - /// - /// Composed once, at startup, mirroring the VS host. Toggled through its predicate by - /// IRemoteProcessTelemetryService.EnableLoggingAsync. - /// - private EtwLogger? _etwLogger; - protected override ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta) - { - _etwLogger = new EtwLogger(EtwLogger.DisabledPredicate); - - return - [ - _etwLogger, - TelemetryLogger.Create(telemetrySession, logDelta), - ]; - } - - /// - internal void UpdateEtwEnablement(bool enabled, Func isEnabled) - => _etwLogger?.UpdatePredicate(enabled ? isEnabled : EtwLogger.DisabledPredicate); + => [TelemetryLogger.Create(telemetrySession, logDelta)]; } From 62d18fd625b18015dbf03e702cf4649802f81c0f Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 19:01:43 -0700 Subject: [PATCH 14/34] Put the LanguageServerTelemetry attribute on its own line The [Export, Shared] attribute had been appended to the closing line. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../Telemetry/LanguageServerTelemetry.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs index 79143056ea3ad..86d106c27c4c1 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs @@ -21,7 +21,8 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Telemetry; /// Owns the standalone language server host's telemetry session: creates and configures it, registers /// the event and metric sinks, and tears everything down on shutdown. The counterpart to /// AbstractWorkspaceTelemetryService in the VS and ServiceHub hosts. -/// [Export, Shared] +/// +[Export, Shared] internal sealed class LanguageServerTelemetry : IDisposable { internal const string CopilotTelemetryLevelEnvironmentVariable = "COPILOT_TELEMETRY_LEVEL"; From dd7bd608022806b92d8dd1b9fccaf192e90a29b2 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 19:23:53 -0700 Subject: [PATCH 15/34] Remove dead telemetry surface and tighten the new comments Dead code: - RecordBlockTime's params-tags overload had no callers, so TimedBlock's tag field and its span/null dance went with it. - minThresholdMs on RecordBlockTime had no non-default caller here or on the API it replaced, LogBlockTimeAggregatedHistogram, so the threshold check was unreachable. TimedBlock is now a primary constructor and a tick. LogBlockTime keeps its threshold; CodeFix_Delay and CodeRefactoring_Delay do pass one. - TelemetryLogger.GetEventName/GetPropertyName were one-line forwarders to TelemetryNaming with a single external caller; FaultReporter now calls TelemetryNaming, which also leaves its Telemetry using unused. - TelemetryNaming.EventPrefix/PropertyPrefix are only read inside the class. - A duplicated using and an unused System.Threading in RoslynTelemetry.Metrics. Comments: - RoslynTelemetry's summary pointed at SetEventSinks/SetMetricSink, which no longer exist. - EtwLogger claimed to stay registered for the process lifetime, which stopped being true for ServiceHub, and DisabledPredicate claimed to be an initial state it is never used as. UpdatePredicate now states why a snapshot predicate has to be swapped at all. - Dropped three IEventSink doc comments that restated the method name; LogBlockStart/LogBlockEnd instead say what uniquePairId and delta are. - Deduplicated VSMetricSink: the flush lock and the test accessor each explained themselves twice, and the class summary said Flush posts and clears immediately after saying measurements are posted by Flush. - Dropped "rather than" phrasings and comments that narrated the code: VSCodeTelemetryReporter announcing two overrides, Program.cs describing what Dispose does internally. Validation: Ide.slnf and Compilers.slnf build clean; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../Program.cs | 1 - .../Services/VSCodeTelemetryReporter.cs | 8 +--- .../Def/Telemetry/Shared/TelemetryLogger.cs | 18 +++----- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 20 +++------ .../Core/Def/Watson/FaultReporter.cs | 3 +- .../OptionPages/PerformanceLoggersPage.cs | 6 +-- src/Workspaces/Core/Portable/Log/EtwLogger.cs | 13 +++--- .../Compiler/Core/Log/IEventSink.cs | 9 ++-- .../Core/Log/RoslynTelemetry.Metrics.cs | 43 +++++-------------- .../Compiler/Core/Log/RoslynTelemetry.cs | 2 +- .../Compiler/Core/Log/TelemetryNaming.cs | 4 +- 11 files changed, 40 insertions(+), 87 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs index d3eeabb9dc357..7e1c92b41a552 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs @@ -198,7 +198,6 @@ serverConfiguration.ClientProcessId is int clientProcessId && } finally { - // After the LSP server shutdown, report session wide telemetry and dispose the session. telemetryService?.Dispose(); } diff --git a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs index 767579f2536a3..4e7a08e993251 100644 --- a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs +++ b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs @@ -22,9 +22,7 @@ internal void SetTelemetryReporter(TelemetryReporterWrapper reporter) _reporter = reporter; } - // This host has no telemetry session of its own; it posts through the language server host's - // session. Override the two methods that do the actual reporting and redirect them through the - // wrapper to the Roslyn reporter. + // This host has no telemetry session of its own; it posts through the language server host's session. protected override void Report(TelemetryEvent telemetryEvent) { @@ -33,9 +31,7 @@ protected override void Report(TelemetryEvent telemetryEvent) public override void ReportMetric(AggregatingTelemetryLog.TelemetryInstrumentEvent metricEvent) { - // Forwarded intact: the aggregated values live on the event's instrument, and only - // TelemetrySession.PostMetricEvent reads them. Flattening to name + properties would discard - // every measurement. + // Forwarded intact: flattening to name + properties would discard every measurement. _reporter?.ReportMetric(metricEvent); } } diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs index 8db8124054f52..b6f41dc807e11 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -68,12 +68,6 @@ protected override void End(object scope, TelemetryResult result) protected abstract bool LogDelta { get; } - internal static string GetEventName(FunctionId id) - => TelemetryNaming.GetEventName(id); - - internal static string GetPropertyName(FunctionId id, string name) - => TelemetryNaming.GetPropertyName(id, name); - public static TelemetryLogger Create(TelemetrySession session, bool logDelta) => Implementation.Create(session, logDelta); @@ -90,7 +84,7 @@ public void Log(FunctionId functionId, LogMessage logMessage) return; } - var telemetryEvent = new TelemetryEvent(GetEventName(functionId)); + var telemetryEvent = new TelemetryEvent(TelemetryNaming.GetEventName(functionId)); SetProperties(telemetryEvent, functionId, logMessage); try @@ -109,7 +103,7 @@ public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int bloc return; } - var eventName = GetEventName(functionId); + var eventName = TelemetryNaming.GetEventName(functionId); var kind = GetKind(logMessage); try @@ -167,14 +161,14 @@ private static void SetProperties(TelemetryEvent telemetryEvent, FunctionId func var message = logMessage.GetMessage(); if (!string.IsNullOrWhiteSpace(message)) { - var propertyName = GetPropertyName(functionId, "Message"); + var propertyName = TelemetryNaming.GetPropertyName(functionId, "Message"); telemetryEvent.Properties.Add(propertyName, message); } } if (delta.HasValue) { - var propertyName = GetPropertyName(functionId, "Delta"); + var propertyName = TelemetryNaming.GetPropertyName(functionId, "Delta"); telemetryEvent.Properties.Add(propertyName, delta.Value); } } @@ -188,7 +182,7 @@ private static void AppendProperties(TelemetryEvent telemetryEvent, FunctionId f // // numeric data will show up in ES with measurement prefix. - telemetryEvent.Properties.Add(GetPropertyName(functionId, name), value switch + telemetryEvent.Properties.Add(TelemetryNaming.GetPropertyName(functionId, name), value switch { PiiValue pii => new TelemetryPiiProperty(pii.Value), IEnumerable items => new TelemetryComplexProperty(items.Select(item => (item is PiiValue pii) ? new TelemetryPiiProperty(pii.Value) : item)), diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index dda22236b6f36..58792ccf5e85f 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -18,11 +18,8 @@ namespace Microsoft.CodeAnalysis.Telemetry; /// /// The aggregating metric sink for one , backed by VS Telemetry's counter -/// and histogram APIs. -/// -/// Measurements accumulate in memory against a VS Telemetry instrument and are posted in batches by -/// , which posts everything accumulated so far and clears. -/// +/// and histogram APIs. Measurements accumulate in memory against an instrument and are posted in batches +/// by . /// /// A host that needs several sessions in one process composes one of these per session behind an /// that routes between them; nothing here needs to change for that. @@ -69,9 +66,6 @@ private sealed class Aggregation(IInstrument instrument, TelemetryEvent telemetr public object Lock { get; } = new(); } - /// - /// Ensures two flushes cannot run at once, which would post the same aggregation twice. - /// private readonly object _flushLock = new(); private readonly VSTelemetryMeterProvider _meterProvider = new(); @@ -90,8 +84,8 @@ private VSMetricSink(IMetricPoster poster) { _poster = poster; - // Owned here rather than by each host, so composing a sink is all a host has to remember. - // Shutdown paths flush explicitly as well, since a host can exit too abruptly for a timer. + // Owned here so that composing a sink is all a host has to remember. Shutdown paths flush + // explicitly as well, since a host can exit too abruptly for a timer. _ = PostCollectedTelemetryAsync(); } @@ -119,11 +113,7 @@ private async Task PostCollectedTelemetryAsync() internal readonly struct TestAccessor { - /// - /// Creates a sink over a caller-supplied poster, so a test can assert exactly how many metric - /// events a flush produces without standing up a real, opted-in - /// (which would try to send). - /// + /// public VSMetricSink CreateSink(IMetricPoster poster) => new(poster); } diff --git a/src/VisualStudio/Core/Def/Watson/FaultReporter.cs b/src/VisualStudio/Core/Def/Watson/FaultReporter.cs index 9200050bb5400..62a39f370c9cf 100644 --- a/src/VisualStudio/Core/Def/Watson/FaultReporter.cs +++ b/src/VisualStudio/Core/Def/Watson/FaultReporter.cs @@ -11,7 +11,6 @@ using System.Threading; using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Remote; -using Microsoft.CodeAnalysis.Telemetry; using Microsoft.VisualStudio.Telemetry; namespace Microsoft.CodeAnalysis.ErrorReporting; @@ -156,7 +155,7 @@ public static void ReportFault(Exception exception, FaultSeverity severity, bool var description = GetDescription(exception); var faultEvent = new FaultEvent( - eventName: TelemetryLogger.GetEventName(FunctionId.NonFatalWatson), + eventName: TelemetryNaming.GetEventName(FunctionId.NonFatalWatson), description: description, severity, exceptionObject: exception, diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs index d9917d5138af7..99ecedc1d52a9 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs @@ -65,9 +65,9 @@ public static void SetLoggers(IGlobalOptionService globalOptions, IThreadingCont var traceEnabled = globalOptions.GetOption(LoggerOptionsStorage.TraceLoggerKey); var outputWindowEnabled = globalOptions.GetOption(LoggerOptionsStorage.OutputWindowLoggerKey); - // The ETW sink is part of VS's shipping composition, because enabling ETW for one FunctionId on - // a customer machine must not require this (non-shipping) VSIX. Its predicate is a snapshot of - // the per-FunctionId options, so it is refreshed rather than replaced. + // Its predicate is a snapshot of the per-FunctionId options, so a registered instance has to be + // told about changes. ETW is registered by the shipping VS composition, because enabling it for + // one FunctionId on a customer machine must not require this (non-shipping) VSIX. var telemetryService = workspaceServices.GetService() as VisualStudioWorkspaceTelemetryService; telemetryService?.UpdateEtwEnablement(etwEnabled, isEnabled); diff --git a/src/Workspaces/Core/Portable/Log/EtwLogger.cs b/src/Workspaces/Core/Portable/Log/EtwLogger.cs index 9fa3d5a2fa721..03602b0d0330e 100644 --- a/src/Workspaces/Core/Portable/Log/EtwLogger.cs +++ b/src/Workspaces/Core/Portable/Log/EtwLogger.cs @@ -9,15 +9,13 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// A sink that publishes events to ETW using an EventSource. Opt-in: enabled per- -/// by a predicate the host can swap at runtime (Tools -> Options -> Performance Loggers). It stays -/// registered for the lifetime of the process; "disabled" means the predicate rejects everything. +/// A sink that publishes events to ETW using an EventSource. Opt-in per , via +/// Tools -> Options -> Performance Loggers or the corresponding registry keys. /// internal sealed class EtwLogger : IEventSink { /// - /// A predicate that rejects every . The initial state for sinks that are off - /// until a user turns them on. + /// A predicate that rejects every , for switching a registered instance off. /// public static readonly Func DisabledPredicate = static _ => false; @@ -31,8 +29,9 @@ public EtwLogger(Func isEnabledPredicate) => _isEnabledPredicate = isEnabledPredicate; /// - /// Replaces the enablement predicate in place. Callers must refresh the composed instance; composing - /// a second one alongside it would post every event twice. + /// Swaps the enablement predicate. Needed because + /// FunctionIdOptions.CreateFunctionIsEnabledPredicate captures a snapshot of the per- + /// options, so an instance built from it does not observe later changes. /// public void UpdatePredicate(Func isEnabledPredicate) => Volatile.Write(ref _isEnabledPredicate, isEnabledPredicate); diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs index d9060b4c5a70c..56b562dfc069a 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs @@ -19,18 +19,17 @@ internal interface IEventSink /// bool IsEnabled(FunctionId functionId); - /// - /// Record a discrete event with context message. - /// void Log(FunctionId functionId, LogMessage logMessage); /// - /// Record the start of a scope with context message. + /// Records the start of a scope. pairs this call with the + /// that closes it. /// void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken); /// - /// Record the end of a scope. + /// Records the end of the scope opened by with the same + /// . is the elapsed milliseconds. /// void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs index 382e9616c53b5..e8680268c8010 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -5,8 +5,6 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.Collections.Immutable; -using System.Threading; namespace Microsoft.CodeAnalysis.Internal.Log; @@ -66,8 +64,8 @@ public static void Count(FunctionId functionId, string metricName, long delta, K } /// - /// Span-based entry point. Kept private because it is ambiguous with the single-tag overload at call - /// sites that use target-typed new(...). + /// Span-based entry point, shared by the fixed-arity overloads above. Not public: it is ambiguous + /// with the single-tag overload at call sites that use target-typed new(...). /// private static void CountCore(FunctionId functionId, string metricName, long delta, ReadOnlySpan> tags) { @@ -121,42 +119,21 @@ private static void RecordCore(FunctionId functionId, string metricName, long va #endregion /// - /// Records the wall-clock duration of the returned scope into a distribution, but only if it meets - /// or exceeds . Returns when no metric sink - /// is configured, so callers can using the result unconditionally. + /// Records the wall-clock duration of the returned scope into a distribution. Returns + /// when no metric sink is configured, so callers can using the result + /// unconditionally. /// - public static IDisposable? RecordBlockTime(FunctionId functionId, string metricName, int minThresholdMs = -1) - => s_metricSinks.IsEmpty ? null : new TimedBlock(functionId, metricName, minThresholdMs, default); - - /// - public static IDisposable? RecordBlockTime(FunctionId functionId, string metricName, int minThresholdMs, params KeyValuePair[] tags) - => s_metricSinks.IsEmpty ? null : new TimedBlock(functionId, metricName, minThresholdMs, tags); + public static IDisposable? RecordBlockTime(FunctionId functionId, string metricName) + => s_metricSinks.IsEmpty ? null : new TimedBlock(functionId, metricName); - private sealed class TimedBlock : IDisposable + private sealed class TimedBlock(FunctionId functionId, string metricName) : IDisposable { - private readonly FunctionId _functionId; - private readonly string _metricName; - private readonly int _minThresholdMs; - private readonly KeyValuePair[]? _tags; - private readonly int _tick; - - public TimedBlock(FunctionId functionId, string metricName, int minThresholdMs, KeyValuePair[]? tags) - { - _functionId = functionId; - _metricName = metricName; - _minThresholdMs = minThresholdMs; - _tags = tags; - _tick = Environment.TickCount; - } + private readonly int _tick = Environment.TickCount; public void Dispose() { // This delta is valid for durations of < 25 days - var delta = Environment.TickCount - _tick; - if (delta < _minThresholdMs) - return; - - RecordCore(_functionId, _metricName, delta, _tags is null ? default : _tags.AsSpan()); + RecordCore(functionId, metricName, Environment.TickCount - _tick, default); } } } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index 3ccc4afeb81b1..63f5da4507df9 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -13,7 +13,7 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// Roslyn's telemetry entry point. Discrete events and scopes are recorded here and fan out to the /// host's configured s; aggregated measurements go to its . /// -/// A host configures this once at startup (see / ). +/// A host configures this once at startup (see / ). /// With nothing configured every method is a cheap no-op, which is the state the build server, the /// CodeStyle packages, and most tests run in. /// diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs index c45d77de8e4b9..3bf95d39950a0 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs @@ -17,8 +17,8 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// internal static class TelemetryNaming { - public const string EventPrefix = "vs/ide/vbcs/"; - public const string PropertyPrefix = "vs.ide.vbcs."; + private const string EventPrefix = "vs/ide/vbcs/"; + private const string PropertyPrefix = "vs.ide.vbcs."; // these don't have concurrency limit on purpose to reduce chance of lock contention. // if that becomes a problem - by showing up in our perf investigation, then we will consider adding concurrency limit. From 97cad27f03e31618281b39fcc924820c3a285cf8 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 19:57:37 -0700 Subject: [PATCH 16/34] Fix regressions found in review Blocking: - The standalone language server passed logDelta: false, so block end events silently lost vs.ide.vbcs..delta. The deleted RoslynLogger passed the delta unconditionally -- it had no logDelta concept -- and the option that gates it in devenv has no counterpart in that host. - RoslynTelemetry.Workspaces.cs referenced RecordBlockTime(FunctionId, string, int), which stopped existing when the unused threshold parameter was removed. CS1574 is not in the repo's NoWarn, so this broke a warnaserror build. Telemetry fidelity: - TimedBlock and TimedEventBlock timed with Environment.TickCount, quantized to ~15.6ms. Every aggregated duration histogram measures sub-100ms IDE operations, so a 3ms code fix pass recorded 0 or 15. The deleted TimedTelemetryLogBlock used SharedStopwatch; both now do again. - That same type suppressed recording in debug bits and under a debugger, and it served the aggregated path too (AggregatingHistogramLog.LogBlockTime returned one). Only the discrete path kept the guard; RecordBlockTime now has it as well, so a breakpoint inside GetMostSevereFixAsync no longer contributes a multi-second sample. Correctness: - Log(FunctionId, LogMessage) freed the pooled message only when a sink was enabled. Callers migrated from TelemetryLogging.Log, which always freed, leaked one message per call -- including PerformanceTrackerService in the OOP process, where no event sink is normally registered. - A block ended on whichever sinks were enabled at dispose, not on the ones it started on, so a sink enabled mid-block received an end with no start. TelemetryLogger asserts the pending scope exists, so that throws; the opposite flip leaks the scope. RoslynLogBlock now records which sinks got the start and ends on exactly those. - VSMetricSink.Flush exchanged the aggregation map before posting, so a concurrent Count/Record missed and built a second instrument with the same name on the same meter while the first was being posted -- the shape of dotnet/roslyn#71606. Cleared after the loop again, as before. - AggregationKey ignored counter-vs-histogram, so one event and metric name used both ways would hand a counter to an IHistogram cast. The old design kept separate dictionaries and could not express this. - An exception from Flush escaped the 30-minute loop, whose task nobody observes, silently ending all later flushes for the session. Also: guard LanguageServerTelemetry's Report/Flush on having a session, since MEF disposes it a second time and the session reporters are not idempotent; drop PerfMarginPanel's registration guard, which AddEventSink already provides and which prevented re-registration after tests clear sinks; remove four dead consts; correct the VS Code Razor ReportMetric comment, which claimed a data loss that cannot occur because that reporter never builds a session manager. Adds RoslynTelemetryTests covering the pairing contract. Validation: Ide.slnf and Compilers.slnf build clean; Workspaces telemetry tests 6/6 on net10.0 and net472; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../Core/Remote/SolutionChecksumUpdater.cs | 2 - .../Telemetry/LanguageServerTelemetry.cs | 14 +- .../Services/VSCodeTelemetryReporter.cs | 2 + .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 36 +++-- .../PerfMargin/PerfMarginPanel.cs | 10 +- .../Log/RoslynTelemetry.Workspaces.cs | 47 +++---- .../CoreTest/Log/RoslynTelemetryTests.cs | 132 ++++++++++++++++++ .../RemoteAssetSynchronizationService.cs | 2 - .../Core/Log/RoslynTelemetry.LogBlock.cs | 30 ++-- .../Core/Log/RoslynTelemetry.Metrics.cs | 25 +++- .../Compiler/Core/Log/RoslynTelemetry.cs | 10 +- .../Compiler/Core/Log/TelemetryNaming.cs | 7 +- 12 files changed, 243 insertions(+), 74 deletions(-) create mode 100644 src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs diff --git a/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs b/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs index 51104bcb5a867..3b02fefbc3731 100644 --- a/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs +++ b/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs @@ -43,8 +43,6 @@ internal sealed class SolutionChecksumUpdater : IDisposable private const string SynchronizeTextChangesStatusSucceededMetricName = "SucceededCount"; private const string SynchronizeTextChangesStatusFailedMetricName = "FailedCount"; - private const string SynchronizeTextChangesStatusSucceededKeyName = nameof(SolutionChecksumUpdater) + "." + SynchronizeTextChangesStatusSucceededMetricName; - private const string SynchronizeTextChangesStatusFailedKeyName = nameof(SolutionChecksumUpdater) + "." + SynchronizeTextChangesStatusFailedMetricName; public SolutionChecksumUpdater( Workspace workspace, diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs index 86d106c27c4c1..a535e067ccb35 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs @@ -94,7 +94,9 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD var metricSink = new VSMetricSink(session); _registrations = [ - RoslynTelemetry.AddEventSink(TelemetryLogger.Create(session, logDelta: false)), + // logDelta: true because block end events from this host have always carried their + // duration; the option that gates it in devenv has no counterpart here. + RoslynTelemetry.AddEventSink(TelemetryLogger.Create(session, logDelta: true)), RoslynTelemetry.AddMetricSink(metricSink), metricSink, ]; @@ -121,13 +123,13 @@ internal static bool IsCopilotCliTelemetryEnabled(string? telemetryLevel) public void Dispose() { - // Ensure that telemetry aggregated over this session is reported and flushed *before* we - // dispose of the telemetry session. - FeaturesSessionTelemetry.Report(); - RoslynTelemetry.Flush(); - if (_telemetrySession is { } session) { + // Report before flushing, so that anything the session-wide reporters record is included + // in the final batch. + FeaturesSessionTelemetry.Report(); + RoslynTelemetry.Flush(); + foreach (var registration in _registrations) registration.Dispose(); diff --git a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs index 4e7a08e993251..7914a629cf0e0 100644 --- a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs +++ b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs @@ -32,6 +32,8 @@ protected override void Report(TelemetryEvent telemetryEvent) public override void ReportMetric(AggregatingTelemetryLog.TelemetryInstrumentEvent metricEvent) { // Forwarded intact: flattening to name + properties would discard every measurement. + // Not reachable yet - this host constructs the base reporter without a session, so no + // AggregatingTelemetryLog exists to call this. _reporter?.ReportMetric(metricEvent); } } diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index 58792ccf5e85f..0fe43693e2645 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -7,6 +7,7 @@ using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; +using Microsoft.CodeAnalysis.ErrorReporting; using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.VisualStudio.Telemetry; @@ -50,7 +51,11 @@ private sealed class SessionPoster(TelemetrySession session) : IMetricPoster public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent) => session.PostMetricEvent(metricEvent); } - private readonly record struct AggregationKey(string EventName, string MetricName, string DimensionKey); + /// + /// Identifies one aggregation bucket. participates so that the same + /// event and metric name used both ways cannot resolve to an instrument of the wrong type. + /// + private readonly record struct AggregationKey(string EventName, string MetricName, string DimensionKey, bool IsCounter); private sealed class Aggregation(IInstrument instrument, TelemetryEvent telemetryEvent) { @@ -90,7 +95,10 @@ private VSMetricSink(IMetricPoster poster) } public void Dispose() - => _flushLoopCancellation.Cancel(); + { + _flushLoopCancellation.Cancel(); + _flushLoopCancellation.Dispose(); + } private async Task PostCollectedTelemetryAsync() { @@ -99,13 +107,16 @@ private async Task PostCollectedTelemetryAsync() try { await Task.Delay(TimeSpan.FromMinutes(30), _flushLoopCancellation.Token).ConfigureAwait(false); + Flush(); } catch (OperationCanceledException) { return; } - - Flush(); + catch (Exception e) when (FatalError.ReportAndCatch(e)) + { + // Keep looping: one failed post must not stop every later flush for this session. + } } } @@ -144,7 +155,10 @@ public void Flush() // Excludes other flushes, which would otherwise post the same aggregation twice. lock (_flushLock) { - var aggregations = Interlocked.Exchange(ref _aggregations, ImmutableDictionary.Empty); + // Cleared only after every post completes. While a flush is in progress a concurrent + // Count/Record still finds the existing aggregation and blocks on its lock, so no second + // instrument is created for a name that is currently being posted. + var aggregations = _aggregations; foreach (var pair in aggregations) { @@ -163,6 +177,8 @@ public void Flush() _poster.Post(aggregation.TelemetryEvent, metricEvent); } } + + _aggregations = ImmutableDictionary.Empty; } } @@ -172,7 +188,7 @@ public void Flush() if (!_poster.IsOptedIn) return null; - var key = new AggregationKey(eventName, metricName, BuildDimensionKey(tags)); + var key = new AggregationKey(eventName, metricName, BuildDimensionKey(tags), isCounter); if (_aggregations.TryGetValue(key, out var existing)) return existing; @@ -180,11 +196,11 @@ public void Flush() return ImmutableInterlocked.GetOrAdd( ref _aggregations, key, - static (key, arg) => arg.self.CreateAggregation(key, arg.tags, arg.isCounter), - (self: this, tags: tags.ToArray(), isCounter)); + static (key, arg) => arg.self.CreateAggregation(key, arg.tags), + (self: this, tags: tags.ToArray())); } - private Aggregation CreateAggregation(AggregationKey key, KeyValuePair[] tags, bool isCounter) + private Aggregation CreateAggregation(AggregationKey key, KeyValuePair[] tags) { var telemetryEvent = new TelemetryEvent(key.EventName); @@ -192,7 +208,7 @@ private Aggregation CreateAggregation(AggregationKey key, KeyValuePair(key.MetricName) : meter.CreateHistogram(key.MetricName); diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs index f779b5370b69d..4476ea430b8f5 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Windows; using System.Windows.Controls; using System.Windows.Threading; @@ -20,7 +19,6 @@ public sealed class PerfMarginPanel : UserControl { private static readonly DataModel s_model = new(); private static readonly PerfEventActivityLogger s_logger = new(s_model); - private static int s_registered; private readonly ListView _mainListView; private readonly Grid _mainGrid; @@ -33,11 +31,9 @@ public sealed class PerfMarginPanel : UserControl public PerfMarginPanel() { - // The tool window can be closed and reopened, constructing another panel over the same static - // model. Attach the sink on the first construction only, and leave it attached so the model - // keeps accumulating while the window is closed. - if (Interlocked.CompareExchange(ref s_registered, 1, 0) == 0) - _ = RoslynTelemetry.AddEventSink(s_logger); + // AddEventSink ignores a sink it already holds, so reopening the tool window cannot register + // the logger twice. It is never unregistered, so the model keeps accumulating while closed. + _ = RoslynTelemetry.AddEventSink(s_logger); // grid _mainGrid = new Grid(); diff --git a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs index 58ff616733473..00594a3d4b661 100644 --- a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs +++ b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; +using Roslyn.Utilities; namespace Microsoft.CodeAnalysis.Internal.Log; @@ -27,51 +28,37 @@ internal static partial class RoslynTelemetry /// /// Posts a discrete event carrying the wall-clock duration of the returned scope, but only if it /// meets or exceeds . Unlike - /// this is not aggregated - each occurrence is + /// this is not aggregated - each occurrence is /// its own event. /// public static IDisposable? LogBlockTime(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs = -1) => TryGetEnabledSinks(functionId, out _) ? new TimedEventBlock(functionId, logMessage, minThresholdMs) : null; - private sealed class TimedEventBlock : IDisposable + private sealed class TimedEventBlock(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs) : IDisposable { - private readonly FunctionId _functionId; - private readonly KeyValueLogMessage _logMessage; - private readonly int _minThresholdMs; - private readonly int _tick; - - public TimedEventBlock(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs) - { - _functionId = functionId; - _logMessage = logMessage; - _minThresholdMs = minThresholdMs; - _tick = Environment.TickCount; - } + private readonly SharedStopwatch _stopwatch = SharedStopwatch.StartNew(); public void Dispose() { - // This delta is valid for durations of < 25 days - var elapsed = Environment.TickCount - _tick; - if (elapsed >= _minThresholdMs) + var elapsed = (long)_stopwatch.Elapsed.TotalMilliseconds; + if (elapsed >= minThresholdMs) { - var logMessage = KeyValueLogMessage.Create(static (m, args) => + // Properties is read inside the setter so that the source message's map is only + // materialized on the path that actually posts. + var message = KeyValueLogMessage.Create(static (m, args) => { - m[TelemetryKeys.Value] = (long)args.elapsed; - m.AddRange(args.properties); - }, (elapsed, properties: _logMessage.Properties)); + m[TelemetryKeys.Value] = args.elapsed; + m.AddRange(args.source.Properties); + }, (elapsed, source: logMessage)); -#if DEBUG - logMessage.Free(); -#else - // Don't skew telemetry results by logging in debug bits or under debugger. - if (Debugger.IsAttached) - logMessage.Free(); + // Don't skew telemetry results by logging in debug bits or under a debugger. + if (IsDebugging) + message.Free(); else - Log(_functionId, logMessage); -#endif + Log(functionId, message); } - _logMessage.Free(); + logMessage.Free(); } } } diff --git a/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs b/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs new file mode 100644 index 0000000000000..069645c535fdd --- /dev/null +++ b/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs @@ -0,0 +1,132 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Threading; +using Microsoft.CodeAnalysis.Internal.Log; +using Xunit; + +namespace Microsoft.CodeAnalysis.UnitTests; + +/// +/// Covers the pairing contract every relies on: a sink receives an end if and +/// only if it received the matching start. Sinks that track pending scopes by block id - the VS +/// telemetry sink does - either throw or leak when that is violated. +/// +public sealed class RoslynTelemetryTests +{ + private sealed class RecordingSink : IEventSink + { + public bool Enabled { get; set; } = true; + + public List<(string Kind, int BlockId)> Events { get; } = []; + + public bool IsEnabled(FunctionId functionId) => Enabled; + + public void Log(FunctionId functionId, LogMessage logMessage) + => Events.Add(("Log", 0)); + + public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken) + => Events.Add(("Start", uniquePairId)); + + public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken) + => Events.Add(("End", uniquePairId)); + } + + [Fact] + public void BlockStartAndEndAreDeliveredAsAPair() + { + var sink = new RecordingSink(); + using var _ = RoslynTelemetry.AddEventSink(sink); + + using (RoslynTelemetry.LogBlock(FunctionId.TestEvent_NotUsed, CancellationToken.None)) + { + } + + Assert.Equal(2, sink.Events.Count); + Assert.Equal("Start", sink.Events[0].Kind); + Assert.Equal("End", sink.Events[1].Kind); + Assert.Equal(sink.Events[0].BlockId, sink.Events[1].BlockId); + } + + [Fact] + public void ASinkEnabledDuringABlockDoesNotSeeAnUnpairedEnd() + { + var alwaysOn = new RecordingSink(); + var initiallyOff = new RecordingSink { Enabled = false }; + + using var _1 = RoslynTelemetry.AddEventSink(alwaysOn); + using var _2 = RoslynTelemetry.AddEventSink(initiallyOff); + + using (RoslynTelemetry.LogBlock(FunctionId.TestEvent_NotUsed, CancellationToken.None)) + { + initiallyOff.Enabled = true; + } + + Assert.Equal(["Start", "End"], alwaysOn.Events.ConvertAll(e => e.Kind)); + Assert.Empty(initiallyOff.Events); + } + + [Fact] + public void ASinkDisabledDuringABlockStillSeesItsEnd() + { + var sink = new RecordingSink(); + using var _ = RoslynTelemetry.AddEventSink(sink); + + using (RoslynTelemetry.LogBlock(FunctionId.TestEvent_NotUsed, CancellationToken.None)) + { + sink.Enabled = false; + } + + // Otherwise the sink's pending scope for this block would never be closed. + Assert.Equal(["Start", "End"], sink.Events.ConvertAll(e => e.Kind)); + Assert.Equal(sink.Events[0].BlockId, sink.Events[1].BlockId); + } + + [Fact] + public void ASinkRegisteredDuringABlockDoesNotSeeAnUnpairedEnd() + { + var first = new RecordingSink(); + using var _1 = RoslynTelemetry.AddEventSink(first); + + var late = new RecordingSink(); + using (RoslynTelemetry.LogBlock(FunctionId.TestEvent_NotUsed, CancellationToken.None)) + { + using var _2 = RoslynTelemetry.AddEventSink(late); + } + + Assert.Equal(["Start", "End"], first.Events.ConvertAll(e => e.Kind)); + Assert.Empty(late.Events); + } + + [Fact] + public void NothingIsDeliveredWhenEverySinkIsDisabled() + { + var sink = new RecordingSink { Enabled = false }; + using var _ = RoslynTelemetry.AddEventSink(sink); + + RoslynTelemetry.Log(FunctionId.TestEvent_NotUsed, "message"); + using (RoslynTelemetry.LogBlock(FunctionId.TestEvent_NotUsed, CancellationToken.None)) + { + } + + Assert.Empty(sink.Events); + } + + [Fact] + public void DisposingARegistrationUnregistersTheSink() + { + var sink = new RecordingSink(); + + var registration = RoslynTelemetry.AddEventSink(sink); + RoslynTelemetry.Log(FunctionId.TestEvent_NotUsed, "before"); + registration.Dispose(); + RoslynTelemetry.Log(FunctionId.TestEvent_NotUsed, "after"); + + Assert.Single(sink.Events); + + // Disposing a second time must not disturb anything. + registration.Dispose(); + } +} diff --git a/src/Workspaces/Remote/ServiceHub/Services/AssetSynchronization/RemoteAssetSynchronizationService.cs b/src/Workspaces/Remote/ServiceHub/Services/AssetSynchronization/RemoteAssetSynchronizationService.cs index 8c7bfee5a0043..4834f000cc2a8 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/AssetSynchronization/RemoteAssetSynchronizationService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/AssetSynchronization/RemoteAssetSynchronizationService.cs @@ -24,8 +24,6 @@ internal sealed class RemoteAssetSynchronizationService(in BrokeredServiceBase.S { private const string SynchronizeTextChangesAsyncSucceededMetricName = "SucceededCount"; private const string SynchronizeTextChangesAsyncFailedMetricName = "FailedCount"; - private const string SynchronizeTextChangesAsyncSucceededKeyName = nameof(RemoteAssetSynchronizationService) + "." + SynchronizeTextChangesAsyncSucceededMetricName; - private const string SynchronizeTextChangesAsyncFailedKeyName = nameof(RemoteAssetSynchronizationService) + "." + SynchronizeTextChangesAsyncFailedMetricName; internal sealed class Factory : FactoryBase { diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs index 8cf020aea7ec8..f908000698f5d 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Immutable; +using System.Diagnostics; using System.Threading; using Microsoft.CodeAnalysis.PooledObjects; using Roslyn.Utilities; @@ -36,23 +37,37 @@ private sealed class RoslynLogBlock(ObjectPool pool) : IDisposab private LogMessage? _logMessage; private CancellationToken _cancellationToken; + /// + /// Bit i is set when _sinks[i] received the start, so that the end goes to exactly that + /// set. A sink's can change while a block is open - + /// TelemetryLogger's tracks the session's opt-in state - and a sink that receives an end + /// it has no start for either throws or leaks the pending scope. + /// + private int _startedSinks; + private FunctionId _functionId; private int _tick; private int _blockId; public void Construct(ImmutableArray sinks, FunctionId functionId, LogMessage logMessage, int blockId, CancellationToken cancellationToken) { + Debug.Assert(sinks.Length <= 32, "More sinks than _startedSinks has bits for."); + _sinks = sinks; _functionId = functionId; _logMessage = logMessage; _tick = Environment.TickCount; _blockId = blockId; _cancellationToken = cancellationToken; + _startedSinks = 0; - foreach (var sink in sinks) + for (var i = 0; i < sinks.Length; i++) { - if (sink.IsEnabled(functionId)) - sink.LogBlockStart(functionId, logMessage, blockId, cancellationToken); + if (sinks[i].IsEnabled(functionId)) + { + _startedSinks |= 1 << i; + sinks[i].LogBlockStart(functionId, logMessage, blockId, cancellationToken); + } } } @@ -68,18 +83,17 @@ public void Dispose() // This delta is valid for durations of < 25 days var delta = Environment.TickCount - _tick; - // Ends on exactly the sinks the block started on: _sinks is the snapshot taken then, so a - // sink added or enabled in between cannot see an unpaired end. - foreach (var sink in _sinks) + for (var i = 0; i < _sinks.Length; i++) { - if (sink.IsEnabled(_functionId)) - sink.LogBlockEnd(_functionId, _logMessage, _blockId, delta, _cancellationToken); + if ((_startedSinks & (1 << i)) != 0) + _sinks[i].LogBlockEnd(_functionId, _logMessage, _blockId, delta, _cancellationToken); } // Free this block back to the pool _logMessage.Free(); _logMessage = null; _sinks = default; + _startedSinks = 0; _cancellationToken = default; pool.Free(this); diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs index e8680268c8010..c9308ea582a18 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; +using Roslyn.Utilities; namespace Microsoft.CodeAnalysis.Internal.Log; @@ -126,14 +128,31 @@ private static void RecordCore(FunctionId functionId, string metricName, long va public static IDisposable? RecordBlockTime(FunctionId functionId, string metricName) => s_metricSinks.IsEmpty ? null : new TimedBlock(functionId, metricName); + /// + /// Whether measurements would be skewed by the environment rather than by the code being measured: + /// debug bits are not representative, and a stopped debugger inflates a duration arbitrarily. + /// + private static bool IsDebugging + { + get + { +#if DEBUG + return true; +#else + return Debugger.IsAttached; +#endif + } + } + private sealed class TimedBlock(FunctionId functionId, string metricName) : IDisposable { - private readonly int _tick = Environment.TickCount; + private readonly SharedStopwatch _stopwatch = SharedStopwatch.StartNew(); public void Dispose() { - // This delta is valid for durations of < 25 days - RecordCore(functionId, metricName, Environment.TickCount - _tick, default); + // Don't skew telemetry results by recording in debug bits or under a debugger. + if (!IsDebugging) + RecordCore(functionId, metricName, (long)_stopwatch.Elapsed.TotalMilliseconds, default); } } } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index 63f5da4507df9..ddd56ae92d41c 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -17,6 +17,11 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// With nothing configured every method is a cheap no-op, which is the state the build server, the /// CodeStyle packages, and most tests run in. /// +/// +/// This file is linked into several assemblies, so "configured" means configured in the assembly the +/// caller resolves to. Only the Workspaces copy is wired up by a host; the CodeStyle copies have no +/// sinks by construction. +/// /// internal static partial class RoslynTelemetry { @@ -182,8 +187,11 @@ public static void Log(FunctionId functionId, LogMessage logMessage) if (TryGetEnabledSinks(functionId, out var sinks)) { LogToSinks(sinks, functionId, logMessage); - logMessage.Free(); } + + // Freed unconditionally: the caller handed over ownership, so returning it to the pool cannot + // depend on whether a sink happened to be listening. + logMessage.Free(); } /// diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs index 3bf95d39950a0..21e1f58c0792c 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs @@ -9,11 +9,8 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// /// Maps onto the event and property names Roslyn's telemetry backend expects. -/// -/// This is the only place the vs/ide/vbcs/ naming convention appears. Sinks receive already-final -/// names, so one sink implementation can serve both Roslyn, whose identity is , -/// and Razor, whose identity is a plain string. -/// +/// Sinks receive already-final names, so one sink implementation can serve both Roslyn, whose identity +/// is , and Razor, whose identity is a plain string. /// internal static class TelemetryNaming { From 1f9746210cbdd9ac5a8565660ee400261173e8db Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 20:02:57 -0700 Subject: [PATCH 17/34] Don't dispose the flush loop's cancellation source The loop reads the token on every iteration, so a source disposed while Flush is running would throw ObjectDisposedException into the new catch-all and spin rather than stop. Cancelling is enough; the loop now also checks the flag. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index 0fe43693e2645..56be07f4ac9d9 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -94,15 +94,16 @@ private VSMetricSink(IMetricPoster poster) _ = PostCollectedTelemetryAsync(); } + /// + /// Stops the periodic flush. The cancellation source is deliberately not disposed: the loop reads + /// its token on every iteration, and a disposed source would throw there instead of stopping. + /// public void Dispose() - { - _flushLoopCancellation.Cancel(); - _flushLoopCancellation.Dispose(); - } + => _flushLoopCancellation.Cancel(); private async Task PostCollectedTelemetryAsync() { - while (true) + while (!_flushLoopCancellation.IsCancellationRequested) { try { From ddb29cfabfaa483cba1569409fabc3fd180ea68d Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 20:19:56 -0700 Subject: [PATCH 18/34] Register the ETW sink on demand in devenv too The reason it was composed at startup does not survive checking. The comment on FunctionIdOptions.CreateOption advertises vsregedit set local [hive] HKCU Roslyn\Internal\Performance\FunctionId [name] dword 1 but the option's config name is "FunctionIdOptions_" + name and there is no FunctionIdOptions_* entry in VisualStudioOptionStorage.Storages. VisualStudioOptionPersister.TryFetch opens with Storages.TryGetValue, so the fetch misses and the value falls through to the default, false; TryPersist misses the same dictionary, so it is not written back either. Contrast visual_studio_etw_logger_key, which does have a LocalUserProfileStorage entry. The only writers of a FunctionIdOptions value are PerformanceFunctionIdPage and PerformanceLoggersPage, both in Roslyn.VisualStudio.DiagnosticsWindow. So without that non-shipping VSIX no FunctionId is ever enabled, and the composed sink's predicate is false forever -- it cannot serve the support scenario it was kept for, because that scenario does not currently work at all. Registering it from the options page alongside the trace and output window sinks therefore loses nothing observable and collapses registration back to one mechanism: composed for what a host always wants, registered on demand for what only the diagnostics VSIX turns on. EtwLogger returns to an immutable predicate, and DisabledPredicate, UpdatePredicate, _etwLogger, UpdateEtwEnablement and the downcast reaching from the VSIX into the shipping assembly all go with it. If the vsregedit workflow is wanted, the fix is a Storages entry, separately -- and with a fresh sink built per apply a live option would take effect without restarting, which a snapshot cannot. Also from review: - TelemetryLogger.LogBlockEnd asserted the pending scope exists, but LogBlockStart stores it inside a swallowing try, so a failure from Start left the bit set and no scope -- turning a swallowed error into a thrown one on an arbitrary Roslyn path. It now returns instead. - VSMetricSink.Flush removes each key while holding that aggregation's lock rather than clearing at the end, so measurements recorded under a new key during a flush survive to the next one. The key stays present for the whole post, so a concurrent Count/Record still blocks rather than building a second instrument with the same name. - RoslynLogBlock's bitmask is bounded by its width. Past it a shift would alias onto bit 0 and hand another sink an unpaired end, which is what the bitmask exists to prevent. - RecordingSink now answers only for TestEvent_NotUsed, so unrelated logging from workspace code cannot land in a test's assertions. Adds a test that block duration reaches the event as a delta property only when the sink asked for it, which is the regression fixed in the previous commit, and makes VSMetricSink's two name derivations internal so they can be pinned directly. Validation: Ide.slnf and Compilers.slnf build clean; Workspaces telemetry tests 10/10 on net10.0 and net472; LSP telemetry tests 18/18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../Telemetry/LanguageServerTelemetry.cs | 2 +- .../Def/Telemetry/Shared/TelemetryLogger.cs | 4 +- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 21 +++--- .../VisualStudioWorkspaceTelemetryService.cs | 19 +----- .../OptionPages/PerformanceLoggersPage.cs | 14 ++-- src/Workspaces/Core/Portable/Log/EtwLogger.cs | 24 +------ .../Log/RoslynTelemetry.Workspaces.cs | 12 +++- .../CoreTest/Log/RoslynTelemetryTests.cs | 64 ++++++++++++++++++- .../RemoteProcessTelemetryService.cs | 2 + .../Core/Log/RoslynTelemetry.LogBlock.cs | 15 ++++- .../Compiler/Core/Log/RoslynTelemetry.cs | 18 ++++-- 11 files changed, 122 insertions(+), 73 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs index a535e067ccb35..1c4132552fe5d 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs @@ -95,7 +95,7 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD _registrations = [ // logDelta: true because block end events from this host have always carried their - // duration; the option that gates it in devenv has no counterpart here. + // duration -- the deleted RoslynLogger added it unconditionally, with no flag involved. RoslynTelemetry.AddEventSink(TelemetryLogger.Create(session, logDelta: true)), RoslynTelemetry.AddMetricSink(metricSink), metricSink, diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs index b6f41dc807e11..3884752f98eed 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs @@ -122,7 +122,9 @@ public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int blockI return; } - Contract.ThrowIfFalse(_pendingScopes.TryRemove(blockId, out var scope)); + // LogBlockStart swallows a failure from Start, so the scope can legitimately be missing here. + if (!_pendingScopes.TryRemove(blockId, out var scope)) + return; var endEvent = GetEndEvent(scope); SetProperties(endEvent, functionId, logMessage, LogDelta ? delta : null); diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index 56be07f4ac9d9..1f6d3427c168a 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -156,16 +156,13 @@ public void Flush() // Excludes other flushes, which would otherwise post the same aggregation twice. lock (_flushLock) { - // Cleared only after every post completes. While a flush is in progress a concurrent - // Count/Record still finds the existing aggregation and blocks on its lock, so no second - // instrument is created for a name that is currently being posted. - var aggregations = _aggregations; - - foreach (var pair in aggregations) + foreach (var pair in _aggregations) { var aggregation = pair.Value; // Excludes concurrent Add/Record on this instrument while the metric event is built - // from it and posted. + // from it and posted. The key stays in the map until the post completes, so a + // concurrent Count/Record finds this aggregation and blocks here rather than building + // a second instrument with the same name. lock (aggregation.Lock) { TelemetryMetricEvent metricEvent = aggregation.Instrument switch @@ -176,10 +173,12 @@ public void Flush() }; _poster.Post(aggregation.TelemetryEvent, metricEvent); + + // Removed per key rather than clearing at the end, so measurements recorded under a + // new key while this loop runs survive to the next flush. + ImmutableInterlocked.TryRemove(ref _aggregations, pair.Key, out _); } } - - _aggregations = ImmutableDictionary.Empty; } } @@ -227,13 +226,13 @@ private IMeter GetOrCreateMeter(string eventName) /// Derives the meter name (vs.ide.vbcs.some.operation.meter) from the event name /// (vs/ide/vbcs/some/operation). /// - private static string GetMeterName(string eventName) + internal static string GetMeterName(string eventName) => eventName.Replace('/', '.') + ".meter"; /// /// Derives a property name (vs.ide.vbcs.some.operation.tagname) from the event name. /// - private static string GetPropertyName(string eventName, string tagName) + internal static string GetPropertyName(string eventName, string tagName) => eventName.Replace('/', '.') + "." + tagName.ToLowerInvariant(); /// diff --git a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs index f1797173596e9..aeb85cf29dddd 100644 --- a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs @@ -32,31 +32,14 @@ internal sealed class VisualStudioWorkspaceTelemetryService( private readonly Lazy _workspace = workspace; private readonly IGlobalOptionService _globalOptions = globalOptions; - /// - /// Composed once, at startup, so that ETW works without the (non-shipping) diagnostics VSIX. Its - /// predicate is a snapshot of the per-FunctionId options, refreshed by the Performance Loggers page. - /// - private EtwLogger? _etwLogger; - protected override ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta) - { - _etwLogger = new EtwLogger(FunctionIdOptions.CreateFunctionIsEnabledPredicate(_globalOptions)); - - return + => [ CodeMarkerLogger.Instance, - _etwLogger, RoslynActivityLogger.Sink, TelemetryLogger.Create(telemetrySession, logDelta), new FileLogger(_globalOptions, _threadingContext), ]; - } - - /// - /// Refreshes the composed ETW sink's enablement, for the Performance Loggers options page. - /// - internal void UpdateEtwEnablement(bool enabled, Func isEnabled) - => _etwLogger?.UpdatePredicate(enabled ? isEnabled : EtwLogger.DisabledPredicate); protected override void TelemetrySessionInitialized() { diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs index 99ecedc1d52a9..2df2c914f23a8 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs @@ -14,7 +14,6 @@ using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.Remote; -using Microsoft.CodeAnalysis.Telemetry; using Microsoft.VisualStudio.ComponentModelHost; using Microsoft.VisualStudio.LanguageServices; using Microsoft.VisualStudio.LanguageServices.Implementation; @@ -31,6 +30,7 @@ internal sealed class PerformanceLoggersPage : AbstractOptionPage private IThreadingContext _threadingContext; private SolutionServices _workspaceServices; + private static IDisposable? s_etwRegistration; private static IDisposable? s_traceRegistration; private static IDisposable? s_outputWindowRegistration; @@ -65,14 +65,10 @@ public static void SetLoggers(IGlobalOptionService globalOptions, IThreadingCont var traceEnabled = globalOptions.GetOption(LoggerOptionsStorage.TraceLoggerKey); var outputWindowEnabled = globalOptions.GetOption(LoggerOptionsStorage.OutputWindowLoggerKey); - // Its predicate is a snapshot of the per-FunctionId options, so a registered instance has to be - // told about changes. ETW is registered by the shipping VS composition, because enabling it for - // one FunctionId on a customer machine must not require this (non-shipping) VSIX. - var telemetryService = workspaceServices.GetService() as VisualStudioWorkspaceTelemetryService; - telemetryService?.UpdateEtwEnablement(etwEnabled, isEnabled); - - // These two exist only for this page, so they are registered while enabled and unregistered - // when not. + // These sinks exist only for this page, so each is registered while enabled and unregistered + // when not. isEnabled is a snapshot of the per-FunctionId options, which is why a fresh sink is + // built on every apply. + Register(ref s_etwRegistration, etwEnabled, () => new EtwLogger(isEnabled)); Register(ref s_traceRegistration, traceEnabled, () => new TraceLogger(isEnabled)); Register(ref s_outputWindowRegistration, outputWindowEnabled, () => new OutputWindowLogger(isEnabled)); diff --git a/src/Workspaces/Core/Portable/Log/EtwLogger.cs b/src/Workspaces/Core/Portable/Log/EtwLogger.cs index 03602b0d0330e..a452ef848e998 100644 --- a/src/Workspaces/Core/Portable/Log/EtwLogger.cs +++ b/src/Workspaces/Core/Portable/Log/EtwLogger.cs @@ -10,34 +10,16 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// /// A sink that publishes events to ETW using an EventSource. Opt-in per , via -/// Tools -> Options -> Performance Loggers or the corresponding registry keys. +/// Tools -> Options -> Performance Loggers. /// -internal sealed class EtwLogger : IEventSink +internal sealed class EtwLogger(Func isEnabledPredicate) : IEventSink { - /// - /// A predicate that rejects every , for switching a registered instance off. - /// - public static readonly Func DisabledPredicate = static _ => false; - // Due to ETW specifics, RoslynEventSource.Instance needs to be initialized during EtwLogger construction // so that we can enable the listeners synchronously before any events are logged. private readonly RoslynEventSource _source = RoslynEventSource.Instance; - private Func _isEnabledPredicate; - - public EtwLogger(Func isEnabledPredicate) - => _isEnabledPredicate = isEnabledPredicate; - - /// - /// Swaps the enablement predicate. Needed because - /// FunctionIdOptions.CreateFunctionIsEnabledPredicate captures a snapshot of the per- - /// options, so an instance built from it does not observe later changes. - /// - public void UpdatePredicate(Func isEnabledPredicate) - => Volatile.Write(ref _isEnabledPredicate, isEnabledPredicate); - public bool IsEnabled(FunctionId functionId) - => _source.IsEnabled() && Volatile.Read(ref _isEnabledPredicate)(functionId); + => _source.IsEnabled() && isEnabledPredicate(functionId); public void Log(FunctionId functionId, LogMessage logMessage) => _source.Log(GetMessage(logMessage), functionId); diff --git a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs index 00594a3d4b661..330ee31463736 100644 --- a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs +++ b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using System; -using System.Diagnostics; using Roslyn.Utilities; namespace Microsoft.CodeAnalysis.Internal.Log; @@ -29,10 +28,17 @@ internal static partial class RoslynTelemetry /// Posts a discrete event carrying the wall-clock duration of the returned scope, but only if it /// meets or exceeds . Unlike /// this is not aggregated - each occurrence is - /// its own event. + /// its own event. Takes ownership of whether or not anything is + /// listening. /// public static IDisposable? LogBlockTime(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs = -1) - => TryGetEnabledSinks(functionId, out _) ? new TimedEventBlock(functionId, logMessage, minThresholdMs) : null; + { + if (TryGetEnabledSinks(functionId, out _)) + return new TimedEventBlock(functionId, logMessage, minThresholdMs); + + logMessage.Free(); + return null; + } private sealed class TimedEventBlock(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs) : IDisposable { diff --git a/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs b/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs index 069645c535fdd..836e34181ebc1 100644 --- a/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs +++ b/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; using System.Collections.Generic; using System.Threading; using Microsoft.CodeAnalysis.Internal.Log; +using Microsoft.CodeAnalysis.UnitTests.Logging; using Xunit; namespace Microsoft.CodeAnalysis.UnitTests; @@ -22,7 +24,7 @@ private sealed class RecordingSink : IEventSink public List<(string Kind, int BlockId)> Events { get; } = []; - public bool IsEnabled(FunctionId functionId) => Enabled; + public bool IsEnabled(FunctionId functionId) => Enabled && functionId == FunctionId.TestEvent_NotUsed; public void Log(FunctionId functionId, LogMessage logMessage) => Events.Add(("Log", 0)); @@ -129,4 +131,64 @@ public void DisposingARegistrationUnregistersTheSink() // Disposing a second time must not disturb anything. registration.Dispose(); } + + /// + /// The overloads that take an already-built own it, so they must return it + /// to the pool even when nothing is listening - otherwise the pool is defeated on exactly the hosts + /// that register no sinks. Observed through the pool handing back the most recently freed instance. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MessagePassingOverloadsFreeTheMessage(bool anySinkEnabled) + { + var sink = new RecordingSink { Enabled = anySinkEnabled }; + using var _ = RoslynTelemetry.AddEventSink(sink); + + AssertReturnedToPool(message => RoslynTelemetry.Log(FunctionId.TestEvent_NotUsed, message)); + AssertReturnedToPool(message => + { + using var block = RoslynTelemetry.LogBlock(FunctionId.TestEvent_NotUsed, message, CancellationToken.None); + }); + AssertReturnedToPool(message => + { + using var block = RoslynTelemetry.LogBlockTime(FunctionId.TestEvent_NotUsed, message); + }); + + static void AssertReturnedToPool(Action log) + { + var message = KeyValueLogMessage.Create(static m => m["key"] = "value"); + log(message); + + var next = KeyValueLogMessage.Create(static m => m["key"] = "value"); + Assert.Same(message, next); + next.Free(); + } + } + + /// + /// The duration of a block reaches the event as a delta property, but only for a sink that + /// asked for it. The standalone language server host asks for it; devenv gates it on an option. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void BlockEndCarriesDeltaOnlyWhenLogDeltaIsSet(bool logDelta) + { + var logger = new TestTelemetryLogger(logDelta); + using var _ = RoslynTelemetry.AddEventSink(logger); + + TestTelemetryLogger.TestScope scope; + + // LogType.UserAction carries LogLevel.Information; anything lower is dropped by the sink. + using (RoslynTelemetry.LogBlock( + FunctionId.TestEvent_NotUsed, + KeyValueLogMessage.Create(LogType.UserAction), + CancellationToken.None)) + { + scope = Assert.Single(logger.OpenedScopes); + } + + Assert.Equal(logDelta, scope.EndEvent.Properties.ContainsKey("vs.ide.vbcs.testevent.notused.delta")); + } } diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs index a15f926aa3536..075590f489673 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs @@ -87,6 +87,8 @@ public ValueTask EnableLoggingAsync(ImmutableArray loggerTypeNames, Immu Register(ref s_traceRegistration, loggerTypeNames.Contains(nameof(TraceLogger)), () => new TraceLogger(logChecker)); }, cancellationToken); + // Its predicate is a snapshot of the per-FunctionId options, so a fresh sink is built on every + // apply. Mirrors the Performance Loggers page, which is the only way these get enabled. static void Register(ref IDisposable? registration, bool enabled, Func create) => Interlocked.Exchange(ref registration, enabled ? RoslynTelemetry.AddEventSink(create()) : null)?.Dispose(); } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs index f908000698f5d..fe8ce828f1a70 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs @@ -31,6 +31,10 @@ public static IDisposable CreateLogBlock(ImmutableArray sinks, Funct /// private sealed class RoslynLogBlock(ObjectPool pool) : IDisposable { + /// + /// How many sinks can track, i.e. its bit width. + /// + private const int MaxTrackedSinks = 32; // these need to be cleared before putting back to pool private ImmutableArray _sinks; @@ -51,7 +55,7 @@ private sealed class RoslynLogBlock(ObjectPool pool) : IDisposab public void Construct(ImmutableArray sinks, FunctionId functionId, LogMessage logMessage, int blockId, CancellationToken cancellationToken) { - Debug.Assert(sinks.Length <= 32, "More sinks than _startedSinks has bits for."); + Debug.Assert(sinks.Length <= MaxTrackedSinks, "More sinks than _startedSinks has bits for."); _sinks = sinks; _functionId = functionId; @@ -61,7 +65,11 @@ public void Construct(ImmutableArray sinks, FunctionId functionId, L _cancellationToken = cancellationToken; _startedSinks = 0; - for (var i = 0; i < sinks.Length; i++) + // Bounded by the bitmask width: a sink past it gets neither start nor end, which keeps the + // pairing correct. Shifting past the width would instead alias onto bit 0 and hand some + // other sink an end it never started. + var trackable = Math.Min(sinks.Length, MaxTrackedSinks); + for (var i = 0; i < trackable; i++) { if (sinks[i].IsEnabled(functionId)) { @@ -83,7 +91,8 @@ public void Dispose() // This delta is valid for durations of < 25 days var delta = Environment.TickCount - _tick; - for (var i = 0; i < _sinks.Length; i++) + var trackable = Math.Min(_sinks.Length, MaxTrackedSinks); + for (var i = 0; i < trackable; i++) { if ((_startedSinks & (1 << i)) != 0) _sinks[i].LogBlockEnd(_functionId, _logMessage, _blockId, delta, _cancellationToken); diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index ddd56ae92d41c..ddbd7856b3850 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -104,7 +104,10 @@ public static void Log(FunctionId functionId, string? message = null, LogLevel l { if (TryGetEnabledSinks(functionId, out var sinks)) { - LogToSinks(sinks, functionId, LogMessage.Create(message ?? "", logLevel: logLevel)); + var logMessage = LogMessage.Create(message ?? "", logLevel: logLevel); + LogToSinks(sinks, functionId, logMessage); + + logMessage.Free(); } } @@ -260,10 +263,15 @@ public static IDisposable LogBlock(FunctionId functi : EmptyLogBlock.Instance; /// - /// log a start and end pair with a context message. + /// log a start and end pair with a context message. Takes ownership of + /// whether or not anything is listening. /// public static IDisposable LogBlock(FunctionId functionId, LogMessage logMessage, CancellationToken token) - => TryGetEnabledSinks(functionId, out var sinks) - ? CreateLogBlock(sinks, functionId, logMessage, GetNextUniqueBlockId(), token) - : EmptyLogBlock.Instance; + { + if (TryGetEnabledSinks(functionId, out var sinks)) + return CreateLogBlock(sinks, functionId, logMessage, GetNextUniqueBlockId(), token); + + logMessage.Free(); + return EmptyLogBlock.Instance; + } } From 9d50d0694e0c7f3aa2192f78b63af7f7a59b7e45 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 26 Aug 2026 20:24:54 -0700 Subject: [PATCH 19/34] Pin the metric naming and document two deliberate gaps RecordBlockTime takes no tags while Count and Record do, so its call sites still build compound metric names by hand. Say why in the doc: migrating would repartition existing buckets, which is the one thing this change is careful not to do. RoslynLogBlock's bitmask records which sinks were started, not where a sink routed that start. Note what a future per-session routing sink owes as a result, since a block is a plain IDisposable and can be disposed on a different execution context than it was created on. Adds the two VSMetricSink assertions with the most Kusto value: the meter and property name derivations, pinned as pure functions, and that a counter and a distribution sharing one event and metric name resolve to separate instruments. Validation: Ide.slnf builds clean; Workspaces telemetry tests 10/10 on net10.0 and net472; VSMetricSink tests 6/6. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../VSMetricSinkTests.cs | 34 +++++++++++++++++++ .../Core/Log/RoslynTelemetry.LogBlock.cs | 8 +++++ .../Core/Log/RoslynTelemetry.Metrics.cs | 5 +++ 3 files changed, 47 insertions(+) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs index cd52007b0bd86..71c9c8a009c3e 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs @@ -103,4 +103,38 @@ public void NothingIsRecordedForAnOptedOutSession() Assert.Empty(poster.Posted); } + + /// + /// The meter and property names are what Kusto queries key off, so they are pinned directly rather + /// than inferred from a posted event. + /// + [Fact] + public void NameDerivationMatchesTheTelemetryConvention() + { + Assert.Equal("vs.ide.vbcs.lsp.requestduration.meter", VSMetricSink.GetMeterName("vs/ide/vbcs/lsp/requestduration")); + Assert.Equal("vs.ide.vbcs.lsp.requestduration.server", VSMetricSink.GetPropertyName("vs/ide/vbcs/lsp/requestduration", "server")); + + // Tag names are lowercased; the event name is already lowercase by construction. + Assert.Equal("vs.ide.vbcs.lsp.requestduration.server", VSMetricSink.GetPropertyName("vs/ide/vbcs/lsp/requestduration", "Server")); + } + + /// + /// One event and metric name used as both a counter and a distribution must resolve to two + /// instruments; sharing one would hand a counter to a histogram cast. + /// + [Fact] + public void CountersAndDistributionsDoNotShareABucket() + { + var poster = new RecordingPoster(); + using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); + + sink.Count("vs/ide/vbcs/test/both", "Value", 1, default); + sink.Record("vs/ide/vbcs/test/both", "Value", 42, default); + + sink.Flush(); + + Assert.Equal(2, poster.Posted.Count); + Assert.Single(poster.Posted, e => e is TelemetryCounterEvent); + Assert.Single(poster.Posted, e => e is TelemetryHistogramEvent); + } } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs index fe8ce828f1a70..0ad02ac729bc0 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs @@ -46,6 +46,14 @@ private sealed class RoslynLogBlock(ObjectPool pool) : IDisposab /// set. A sink's can change while a block is open - /// TelemetryLogger's tracks the session's opt-in state - and a sink that receives an end /// it has no start for either throws or leaks the pending scope. + /// + /// This records which sinks were started, not where a sink routed that start. A sink + /// that resolves a per-session target from an AsyncLocal occupies one bit here, and a + /// block is a plain not bound to an await, so it can be disposed on a + /// different execution context than it was created on. Such a sink must capture its resolved + /// target per block id at the start and reuse it at the end rather than re-resolving; nothing + /// here needs to change for that. + /// /// private int _startedSinks; diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs index c9308ea582a18..45402f0c4dedc 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -124,6 +124,11 @@ private static void RecordCore(FunctionId functionId, string metricName, long va /// Records the wall-clock duration of the returned scope into a distribution. Returns /// when no metric sink is configured, so callers can using the result /// unconditionally. + /// + /// Unlike and + /// this takes no tags, so the metric name is the whole bucket and call sites build compound names by + /// hand. Migrating it would repartition existing buckets, so it is deliberately left alone. + /// /// public static IDisposable? RecordBlockTime(FunctionId functionId, string metricName) => s_metricSinks.IsEmpty ? null : new TimedBlock(functionId, metricName); From c1fe39b0c526d703e9d97055e80d38d7ac75674a Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 27 Aug 2026 11:14:27 -0700 Subject: [PATCH 20/34] Drop forward-looking notes from the telemetry comments The comments now describe what the code does, not what someone might do to it later: the Logger shim no longer says when it should be deleted, RecordBlockTime states that its metric name is the whole bucket without arguing about migrating it, RoslynLogBlock's bitmask documents the invariant it enforces without speculating about routing sinks, and the Razor bridge states that ReportMetric is unreachable in that host rather than "not yet". Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../Services/VSCodeTelemetryReporter.cs | 4 ++-- .../Compiler/Core/Log/Logger.cs | 9 ++------- .../Compiler/Core/Log/RoslynTelemetry.LogBlock.cs | 8 -------- .../Compiler/Core/Log/RoslynTelemetry.Metrics.cs | 4 ++-- 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs index 7914a629cf0e0..0b9ed86b4ab98 100644 --- a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs +++ b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs @@ -32,8 +32,8 @@ protected override void Report(TelemetryEvent telemetryEvent) public override void ReportMetric(AggregatingTelemetryLog.TelemetryInstrumentEvent metricEvent) { // Forwarded intact: flattening to name + properties would discard every measurement. - // Not reachable yet - this host constructs the base reporter without a session, so no - // AggregatingTelemetryLog exists to call this. + // Unreachable in this host, which constructs the base reporter without a session, so no + // AggregatingTelemetryLog is created to call it. _reporter?.ReportMetric(metricEvent); } } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs index 1ba499378679a..f23489e805edb 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs @@ -8,13 +8,8 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// Forwarding shim onto . New code should call -/// directly; this exists only to serve existing call sites and is -/// intended to be deleted once they have been updated. -/// -/// It carries no because the repository builds with warnings as errors -/// and its remaining call sites are expected, not accidental. -/// +/// Forwarding shim onto , for call sites that have not moved over. New +/// code should call directly. /// internal static class Logger { diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs index 0ad02ac729bc0..fe8ce828f1a70 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs @@ -46,14 +46,6 @@ private sealed class RoslynLogBlock(ObjectPool pool) : IDisposab /// set. A sink's can change while a block is open - /// TelemetryLogger's tracks the session's opt-in state - and a sink that receives an end /// it has no start for either throws or leaks the pending scope. - /// - /// This records which sinks were started, not where a sink routed that start. A sink - /// that resolves a per-session target from an AsyncLocal occupies one bit here, and a - /// block is a plain not bound to an await, so it can be disposed on a - /// different execution context than it was created on. Such a sink must capture its resolved - /// target per block id at the start and reuse it at the end rather than re-resolving; nothing - /// here needs to change for that. - /// /// private int _startedSinks; diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs index 45402f0c4dedc..05221145a3228 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -126,8 +126,8 @@ private static void RecordCore(FunctionId functionId, string metricName, long va /// unconditionally. /// /// Unlike and - /// this takes no tags, so the metric name is the whole bucket and call sites build compound names by - /// hand. Migrating it would repartition existing buckets, so it is deliberately left alone. + /// this takes no tags: the metric name is the whole bucket, so call sites that need dimensions build + /// a compound name. /// /// public static IDisposable? RecordBlockTime(FunctionId functionId, string metricName) From 03f12e8cbb47c337f075b2d7092f46fdb1e92da0 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 27 Aug 2026 13:17:11 -0700 Subject: [PATCH 21/34] Flush the language server's request telemetry when a server shuts down VSCodeRequestTelemetryLogger was exported as a stateless LSP service, and LspServices only tracks non-stateless services for disposal: // Stateless LSP services will be disposed of on MEF container disposal. var checkDisposal = !lazyService.Metadata.IsStateless && !lazyService.IsValueCreated; So RequestTelemetryLogger.Dispose never ran on shutdown in the standalone host, and its own comment -- "Ensure that telemetry logged for this server instance is flushed before potentially creating a new instance" -- was not honored there. The VS host was unaffected: RequestTelemetryLoggerFactory is an ExportCSharpVisualBasicLspServiceFactory, which is stateful. Nothing was lost, since the process-wide sink still flushed on its timer and at process exit, but in daemon mode a server's telemetry was never attributed to its own shutdown. Exporting it through a factory matches the VS host and gives each server its own logger, which is also what the per-server ServerTypeName implies. Adds two tests: - LanguageServerRequestTelemetryTests drives a real language server, then shuts it down and asserts the aggregated request telemetry reaches the session. Verified non-vacuous: it fails against the stateless export. It also pins that the method tag splits buckets -- initialize and initialized produce separate instruments under one event name. - ServiceHubServicesTests_Telemetry drives InitializeTelemetrySessionAsync over the brokered service and asserts the remote host's connect event reaches the sinks that call configured. Validation: LSP telemetry tests 21/21; OOP telemetry test passes on net472. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../LanguageServerRequestTelemetryTests.cs | 63 +++++++++++++++++++ .../Telemetry/VSCodeRequestTelemetryLogger.cs | 16 ++++- .../ServiceHubServicesTests_Telemetry.cs | 58 +++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs create mode 100644 src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs new file mode 100644 index 0000000000000..deb796bfac70b --- /dev/null +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable disable + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Internal.Log; +using Microsoft.CodeAnalysis.Telemetry; +using Microsoft.VisualStudio.Telemetry; +using Microsoft.VisualStudio.Telemetry.Metrics.Events; +using Roslyn.LanguageServer.Protocol; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests; + +/// +/// Covers the language server's request telemetry end to end: a real LSP request records aggregated +/// measurements, and shutting the server down posts them to the telemetry session. +/// +public sealed class LanguageServerRequestTelemetryTests(ITestOutputHelper testOutputHelper) + : AbstractLanguageServerHostTests(testOutputHelper) +{ + private sealed class RecordingPoster : VSMetricSink.IMetricPoster + { + public List PostedEvents { get; } = []; + + public bool IsOptedIn => true; + + public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent) + => PostedEvents.Add(telemetryEvent); + } + + [Fact] + public async Task RealRequestsProduceAggregatedTelemetry() + { + var poster = new RecordingPoster(); + using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); + using var registration = RoslynTelemetry.AddMetricSink(sink); + + var server = await CreateLanguageServerAsync(); + + // Measurements accumulate against instruments; nothing is posted until a flush. + Assert.Empty(poster.PostedEvents); + + // Shutting the server down disposes its RequestTelemetryLogger, whose Dispose flushes. + await server.DisposeAsync(); + + // One event per instrument, and the method tag discriminates buckets: initialize and + // initialized are separate instruments under the same event name. + var durations = poster.PostedEvents.FindAll(e => e.Name == "vs/ide/vbcs/lsp/requestduration"); + Assert.Contains(durations, e => Equals(e.Properties["vs.ide.vbcs.lsp.requestduration.method"], Methods.InitializeName)); + Assert.Contains(durations, e => Equals(e.Properties["vs.ide.vbcs.lsp.requestduration.method"], Methods.InitializedName)); + Assert.All(durations, e => Assert.Equal("CSharpVisualBasicLanguageServerFactory", e.Properties["vs.ide.vbcs.lsp.requestduration.server"])); + + var counters = poster.PostedEvents.FindAll(e => e.Name == "vs/ide/vbcs/lsp/requestcounter"); + Assert.Contains(counters, e => Equals(e.Properties["vs.ide.vbcs.lsp.requestcounter.method"], Methods.InitializeName)); + + Assert.Contains(poster.PostedEvents, e => e.Name == "vs/ide/vbcs/lsp/timeinqueue"); + } +} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs index 5fb06ee2740e3..bb6d54ad153da 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -10,9 +10,21 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Telemetry; -[ExportCSharpVisualBasicStatelessLspService(typeof(RequestTelemetryLogger), serverKind: WellKnownLspServerKinds.CSharpVisualBasicLspServer), Shared] +/// +/// Exported through a factory rather than as a stateless service so that the logger is created per +/// server and disposed when that server shuts down. only tracks non-stateless +/// services for disposal, and is what flushes a server's +/// aggregated request telemetry. +/// +[ExportCSharpVisualBasicLspServiceFactory(typeof(RequestTelemetryLogger), WellKnownLspServerKinds.CSharpVisualBasicLspServer), Shared] [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class VSCodeRequestTelemetryLoggerFactory() : ILspServiceFactory +{ + public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind) + => new VSCodeRequestTelemetryLogger(); +} + internal sealed class VSCodeRequestTelemetryLogger() : RequestTelemetryLogger(WellKnownLspServerKinds.CSharpVisualBasicLspServer.ToTelemetryString()) { /// diff --git a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs new file mode 100644 index 0000000000000..e58cf7f4aa21d --- /dev/null +++ b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable disable + +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Internal.Log; +using Microsoft.CodeAnalysis.Remote; +using Microsoft.CodeAnalysis.Remote.Testing; +using Microsoft.CodeAnalysis.Telemetry; +using Microsoft.CodeAnalysis.UnitTests.Logging; +using Microsoft.VisualStudio.Telemetry; +using Xunit; + +namespace Roslyn.VisualStudio.Next.UnitTests.Remote; + +public sealed partial class ServiceHubServicesTests +{ + /// + /// Covers the OOP process's half of telemetry setup: initializing a session over the brokered + /// service configures the remote host, and telemetry logged there reaches the configured sinks. + /// + [Fact] + public async Task TestRemoteProcessTelemetrySessionInitialization() + { + using var workspace = CreateWorkspace(); + using var client = await InProcRemoteHostClient.GetTestClientAsync(workspace).ConfigureAwait(false); + + var logger = new TestTelemetryLogger(); + using var registration = RoslynTelemetry.AddEventSink(logger); + + // Stands in for the settings the VS host serializes across. The collector key is a syntactically + // valid placeholder and the level is off, so the real sink this also installs in the remote host + // has nothing to send and nowhere to send it. + var processStartTime = Process.GetCurrentProcess().StartTime.ToFileTimeUtc(); + var settings = $$""" + {"Id":"{{Guid.NewGuid()}}","HostName":"Default","AppId":1000,"TelemetryLevel":"off","CollectorApiKey":"00000000000000000000000000000000-00000000-0000-0000-0000-000000000000-0000","ProcessStartTime":{{processStartTime}}} + """; + var hostProcessId = Process.GetCurrentProcess().Id; + + var succeeded = await client.TryInvokeAsync( + (service, cancellationToken) => service.InitializeTelemetrySessionAsync( + hostProcessId, settings, logDelta: false, cancellationToken), + CancellationToken.None); + + Assert.True(succeeded); + + // The remote host logs that it connected as the last step of initialization. + var connect = Assert.Single(logger.PostedEvents, e => e.Name == "vs/ide/vbcs/remotehost/connect"); + Assert.Equal(hostProcessId, connect.Properties["vs.ide.vbcs.remotehost.connect.host"]); + Assert.Equal(RuntimeInformation.FrameworkDescription, connect.Properties["vs.ide.vbcs.remotehost.connect.framework"]); + } +} From 4f9491a81c01b67dc38705ea67edc8548c6edb97 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 27 Aug 2026 13:27:17 -0700 Subject: [PATCH 22/34] Test OOP logger enablement without a telemetry session The previous version drove InitializeTelemetrySessionAsync, which starts a real TelemetrySession; that reaches TelemetryManifestManager and RemoteControlClient, so it attempts network access and cannot run under CI network isolation. EnableLoggingAsync needs no session, and it is the API this change actually rewrote -- ETW and trace sinks in the OOP process are now registered while enabled and unregistered when not, rather than composed with a mutable predicate. The test observes registration through whether anything is listening for a FunctionId, which is the same short-circuit that makes logging free when nothing is; Trace output is not usable because the test host captures no listeners. Verified non-vacuous: it fails if the registration is not disposed when logging is turned back off. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../ServiceHubServicesTests_Telemetry.cs | 67 ++++++++++--------- .../RemoteProcessTelemetryService.cs | 2 +- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs index e58cf7f4aa21d..04a3fdb032763 100644 --- a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs +++ b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs @@ -4,17 +4,12 @@ #nullable disable -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; +using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Remote; using Microsoft.CodeAnalysis.Remote.Testing; -using Microsoft.CodeAnalysis.Telemetry; -using Microsoft.CodeAnalysis.UnitTests.Logging; -using Microsoft.VisualStudio.Telemetry; using Xunit; namespace Roslyn.VisualStudio.Next.UnitTests.Remote; @@ -22,37 +17,45 @@ namespace Roslyn.VisualStudio.Next.UnitTests.Remote; public sealed partial class ServiceHubServicesTests { /// - /// Covers the OOP process's half of telemetry setup: initializing a session over the brokered - /// service configures the remote host, and telemetry logged there reaches the configured sinks. + /// The Performance Loggers options page pushes diagnostic logger enablement into the OOP process + /// over this API. Covers that the remote host registers a sink while it is enabled and unregisters + /// it when it is not, observed through whether anything is listening for a given + /// - which is also what makes logging free when nothing is. /// [Fact] - public async Task TestRemoteProcessTelemetrySessionInitialization() + public async Task TestRemoteProcessEnableLogging() { using var workspace = CreateWorkspace(); using var client = await InProcRemoteHostClient.GetTestClientAsync(workspace).ConfigureAwait(false); - var logger = new TestTelemetryLogger(); - using var registration = RoslynTelemetry.AddEventSink(logger); - - // Stands in for the settings the VS host serializes across. The collector key is a syntactically - // valid placeholder and the level is off, so the real sink this also installs in the remote host - // has nothing to send and nowhere to send it. - var processStartTime = Process.GetCurrentProcess().StartTime.ToFileTimeUtc(); - var settings = $$""" - {"Id":"{{Guid.NewGuid()}}","HostName":"Default","AppId":1000,"TelemetryLevel":"off","CollectorApiKey":"00000000000000000000000000000000-00000000-0000-0000-0000-000000000000-0000","ProcessStartTime":{{processStartTime}}} - """; - var hostProcessId = Process.GetCurrentProcess().Id; - - var succeeded = await client.TryInvokeAsync( - (service, cancellationToken) => service.InitializeTelemetrySessionAsync( - hostProcessId, settings, logDelta: false, cancellationToken), - CancellationToken.None); - - Assert.True(succeeded); - - // The remote host logs that it connected as the last step of initialization. - var connect = Assert.Single(logger.PostedEvents, e => e.Name == "vs/ide/vbcs/remotehost/connect"); - Assert.Equal(hostProcessId, connect.Properties["vs.ide.vbcs.remotehost.connect.host"]); - Assert.Equal(RuntimeInformation.FrameworkDescription, connect.Properties["vs.ide.vbcs.remotehost.connect.framework"]); + Assert.False(AnythingIsListening(FunctionId.TestEvent_NotUsed)); + + Assert.True(await SetRemoteLoggingAsync([nameof(TraceLogger)], [FunctionId.TestEvent_NotUsed])); + Assert.True(AnythingIsListening(FunctionId.TestEvent_NotUsed)); + + // The sink was built with a predicate covering only the requested ids. + Assert.False(AnythingIsListening(FunctionId.RemoteHost_Connect)); + + Assert.True(await SetRemoteLoggingAsync([], [])); + Assert.False(AnythingIsListening(FunctionId.TestEvent_NotUsed)); + + Task SetRemoteLoggingAsync(ImmutableArray loggerTypeNames, ImmutableArray functionIds) + => client.TryInvokeAsync( + (service, cancellationToken) => service.EnableLoggingAsync(loggerTypeNames, functionIds, cancellationToken), + CancellationToken.None).AsTask(); + + // RoslynTelemetry only builds a message when some sink is enabled for the id, so the message + // factory running is exactly "a sink is registered and wants this id". + static bool AnythingIsListening(FunctionId functionId) + { + var listening = false; + RoslynTelemetry.Log(functionId, () => + { + listening = true; + return string.Empty; + }); + + return listening; + } } } diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs index 075590f489673..9255689629c6e 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. From af1028f176950515c82b0cc09f5525eb32246046 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 27 Aug 2026 13:45:01 -0700 Subject: [PATCH 23/34] Revert the block pairing bitmask The unpaired-block hazard it addressed is not caused by this change: the AggregateLogger it replaced re-evaluated IsEnabled in both LogBlockStart and LogBlockEnd exactly the same way. Fixing it here was scope creep, and the bitmask carried its own cost -- a width constant, two Math.Min bounds, and a silent "a sink past the width gets nothing" rule that existed only to make the mask safe. RoslynLogBlock now differs from the original only where it has to: it holds the sink array instead of one ILogger, and runs the per-sink IsEnabled loops that AggregateLogger used to run. TelemetryLogger.LogBlockEnd keeps its graceful return. That one is a regression this change introduced rather than a pre-existing issue: the standalone host used to log through RoslynLogger, whose LogBlockEnd returned when the scope was missing, and consolidating onto TelemetryLogger subjected it to a Contract.ThrowIfFalse instead. Drops the two tests that asserted the reverted behavior; the ones covering sinks registered mid-block, fan-out, registration disposal, and the pooled message contract still hold. Validation: Compilers.slnf builds clean; Workspaces telemetry tests 8/8 on net10.0 and net472; LSP telemetry tests 21/21. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../CoreTest/Log/RoslynTelemetryTests.cs | 39 +------------------ .../Core/Log/RoslynTelemetry.LogBlock.cs | 37 +++--------------- 2 files changed, 8 insertions(+), 68 deletions(-) diff --git a/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs b/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs index 836e34181ebc1..a518e7af4ce6f 100644 --- a/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs +++ b/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs @@ -12,9 +12,8 @@ namespace Microsoft.CodeAnalysis.UnitTests; /// -/// Covers the pairing contract every relies on: a sink receives an end if and -/// only if it received the matching start. Sinks that track pending scopes by block id - the VS -/// telemetry sink does - either throw or leak when that is violated. +/// Covers how events and scopes fan out to registered sinks, and that the overloads taking a pooled +/// return it whether or not anything is listening. /// public sealed class RoslynTelemetryTests { @@ -52,40 +51,6 @@ public void BlockStartAndEndAreDeliveredAsAPair() Assert.Equal(sink.Events[0].BlockId, sink.Events[1].BlockId); } - [Fact] - public void ASinkEnabledDuringABlockDoesNotSeeAnUnpairedEnd() - { - var alwaysOn = new RecordingSink(); - var initiallyOff = new RecordingSink { Enabled = false }; - - using var _1 = RoslynTelemetry.AddEventSink(alwaysOn); - using var _2 = RoslynTelemetry.AddEventSink(initiallyOff); - - using (RoslynTelemetry.LogBlock(FunctionId.TestEvent_NotUsed, CancellationToken.None)) - { - initiallyOff.Enabled = true; - } - - Assert.Equal(["Start", "End"], alwaysOn.Events.ConvertAll(e => e.Kind)); - Assert.Empty(initiallyOff.Events); - } - - [Fact] - public void ASinkDisabledDuringABlockStillSeesItsEnd() - { - var sink = new RecordingSink(); - using var _ = RoslynTelemetry.AddEventSink(sink); - - using (RoslynTelemetry.LogBlock(FunctionId.TestEvent_NotUsed, CancellationToken.None)) - { - sink.Enabled = false; - } - - // Otherwise the sink's pending scope for this block would never be closed. - Assert.Equal(["Start", "End"], sink.Events.ConvertAll(e => e.Kind)); - Assert.Equal(sink.Events[0].BlockId, sink.Events[1].BlockId); - } - [Fact] public void ASinkRegisteredDuringABlockDoesNotSeeAnUnpairedEnd() { diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs index fe8ce828f1a70..c78ddcea4d509 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Immutable; -using System.Diagnostics; using System.Threading; using Microsoft.CodeAnalysis.PooledObjects; using Roslyn.Utilities; @@ -31,51 +30,29 @@ public static IDisposable CreateLogBlock(ImmutableArray sinks, Funct /// private sealed class RoslynLogBlock(ObjectPool pool) : IDisposable { - /// - /// How many sinks can track, i.e. its bit width. - /// - private const int MaxTrackedSinks = 32; // these need to be cleared before putting back to pool private ImmutableArray _sinks; private LogMessage? _logMessage; private CancellationToken _cancellationToken; - /// - /// Bit i is set when _sinks[i] received the start, so that the end goes to exactly that - /// set. A sink's can change while a block is open - - /// TelemetryLogger's tracks the session's opt-in state - and a sink that receives an end - /// it has no start for either throws or leaks the pending scope. - /// - private int _startedSinks; - private FunctionId _functionId; private int _tick; private int _blockId; public void Construct(ImmutableArray sinks, FunctionId functionId, LogMessage logMessage, int blockId, CancellationToken cancellationToken) { - Debug.Assert(sinks.Length <= MaxTrackedSinks, "More sinks than _startedSinks has bits for."); - _sinks = sinks; _functionId = functionId; _logMessage = logMessage; _tick = Environment.TickCount; _blockId = blockId; _cancellationToken = cancellationToken; - _startedSinks = 0; - // Bounded by the bitmask width: a sink past it gets neither start nor end, which keeps the - // pairing correct. Shifting past the width would instead alias onto bit 0 and hand some - // other sink an end it never started. - var trackable = Math.Min(sinks.Length, MaxTrackedSinks); - for (var i = 0; i < trackable; i++) + foreach (var sink in sinks) { - if (sinks[i].IsEnabled(functionId)) - { - _startedSinks |= 1 << i; - sinks[i].LogBlockStart(functionId, logMessage, blockId, cancellationToken); - } + if (sink.IsEnabled(functionId)) + sink.LogBlockStart(functionId, logMessage, blockId, cancellationToken); } } @@ -91,18 +68,16 @@ public void Dispose() // This delta is valid for durations of < 25 days var delta = Environment.TickCount - _tick; - var trackable = Math.Min(_sinks.Length, MaxTrackedSinks); - for (var i = 0; i < trackable; i++) + foreach (var sink in _sinks) { - if ((_startedSinks & (1 << i)) != 0) - _sinks[i].LogBlockEnd(_functionId, _logMessage, _blockId, delta, _cancellationToken); + if (sink.IsEnabled(_functionId)) + sink.LogBlockEnd(_functionId, _logMessage, _blockId, delta, _cancellationToken); } // Free this block back to the pool _logMessage.Free(); _logMessage = null; _sinks = default; - _startedSinks = 0; _cancellationToken = default; pool.Free(this); From e05ecb054c627290218aec0b5691bf52e2378925 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 27 Aug 2026 14:45:25 -0700 Subject: [PATCH 24/34] Apply PR feedback on comments Trims or rewords the doc comments called out in review: shorter summaries on RoslynTelemetry, IEventSink, IMetricSink, TelemetryNaming, Logger, LanguageServerTelemetry, VSMetricSink and the Razor bridge, and removes the ones that restated the code or explained a decision rather than the behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../Razor/TelemetryReporterWrapper.cs | 11 --------- .../Telemetry/LanguageServerTelemetry.cs | 16 ++----------- .../Telemetry/VSCodeRequestTelemetryLogger.cs | 5 +--- ...ILanguageServerTelemetryReporterWrapper.cs | 9 +------ .../Services/VSCodeTelemetryReporter.cs | 3 --- .../Core/Def/RoslynActivityLogger.cs | 5 ---- .../Def/Telemetry/Shared/TelemetryLogger.cs | 2 +- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 22 +++-------------- .../PerfMargin/PerfMarginPanel.cs | 3 +-- .../Log/RoslynTelemetry.Workspaces.cs | 6 ++--- .../CoreTest/Log/RoslynTelemetryTests.cs | 2 +- .../Compiler/Core/Log/IEventSink.cs | 7 ++---- .../Compiler/Core/Log/IMetricSink.cs | 6 ----- .../Compiler/Core/Log/Logger.cs | 3 +-- .../Core/Log/RoslynTelemetry.Metrics.cs | 7 ++---- .../Compiler/Core/Log/RoslynTelemetry.cs | 24 ++++--------------- .../Compiler/Core/Log/TelemetryNaming.cs | 2 -- 17 files changed, 22 insertions(+), 111 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs index f3b01ba2bd1c6..34356cf15c4b1 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/Razor/TelemetryReporterWrapper.cs @@ -13,12 +13,6 @@ namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.Razor; /// /// Lets Razor's VS Code extension post telemetry through this host's session, which it does not own. -/// The dependency runs Roslyn -> Razor, so Razor declares the contract and this implements it. -/// -/// Razor's names and properties are already final when they arrive - they do not go through Roslyn's -/// FunctionId pipeline - so this posts to the session directly. It only reads the session; -/// ownership and disposal stay with . -/// /// [Shared] [Export(typeof(ILanguageServerTelemetryReporterWrapper))] @@ -38,11 +32,6 @@ public void ReportEvent(string name, List> propert session.PostEvent(telemetryEvent); } - /// - /// Posts an aggregated measurement. The event must arrive intact: its aggregated values live on its - /// instrument, and only reads them. Flattening it to - /// a name and property bag would discard every measurement. - /// public void ReportMetric(TelemetryMetricEvent metricEvent) => telemetryService?.Value.Session?.PostMetricEvent(metricEvent); } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs index 1c4132552fe5d..63758bdd7d74b 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs @@ -18,9 +18,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Telemetry; /// -/// Owns the standalone language server host's telemetry session: creates and configures it, registers -/// the event and metric sinks, and tears everything down on shutdown. The counterpart to -/// AbstractWorkspaceTelemetryService in the VS and ServiceHub hosts. +/// Initializes language server telemetry using a standalone session or from C#DK. Flushes telemetry on shutdown. /// [Export, Shared] internal sealed class LanguageServerTelemetry : IDisposable @@ -42,8 +40,7 @@ internal sealed class LanguageServerTelemetry : IDisposable private TelemetrySession? _telemetrySession; /// - /// Everything this type registered or owns, in the order it must be torn down: sink registrations - /// first, then the sinks themselves. + /// Ordered list of sinks that must be disposed of on shutdown. /// private ImmutableArray _registrations = []; @@ -94,8 +91,6 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD var metricSink = new VSMetricSink(session); _registrations = [ - // logDelta: true because block end events from this host have always carried their - // duration -- the deleted RoslynLogger added it unconditionally, with no flag involved. RoslynTelemetry.AddEventSink(TelemetryLogger.Create(session, logDelta: true)), RoslynTelemetry.AddMetricSink(metricSink), metricSink, @@ -114,19 +109,12 @@ internal static bool IsCopilotCliTelemetryEnabled(string? telemetryLevel) ? serverConfiguration.TelemetryLevel : Environment.GetEnvironmentVariable(CopilotTelemetryLevelEnvironmentVariable); - /// - /// The active session, for the one component that posts through it directly: Razor's VS Code - /// extension, which owns no session of its own. Roslyn's own telemetry goes through - /// and the registered sinks. - /// internal TelemetrySession? Session => _telemetrySession; public void Dispose() { if (_telemetrySession is { } session) { - // Report before flushing, so that anything the session-wide reporters record is included - // in the final batch. FeaturesSessionTelemetry.Report(); RoslynTelemetry.Flush(); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs index bb6d54ad153da..9e82a7bb38fb2 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs @@ -11,10 +11,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Telemetry; /// -/// Exported through a factory rather than as a stateless service so that the logger is created per -/// server and disposed when that server shuts down. only tracks non-stateless -/// services for disposal, and is what flushes a server's -/// aggregated request telemetry. +/// Exports a stateful that reports server specific telemetry. /// [ExportCSharpVisualBasicLspServiceFactory(typeof(RequestTelemetryLogger), WellKnownLspServerKinds.CSharpVisualBasicLspServer), Shared] [method: ImportingConstructor] diff --git a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs index 038778cdb3fb7..823fdc8fe9e34 100644 --- a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs +++ b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/ILanguageServerTelemetryReporterWrapper.cs @@ -7,18 +7,11 @@ namespace Microsoft.VisualStudioCode.RazorExtension.Services; /// -/// Lets Razor's VS Code extension post telemetry through the language server host's session, which it -/// does not own. Implemented on the Roslyn side; the dependency runs Roslyn -> Razor, so Razor declares -/// the contract and Roslyn supplies it. +/// Wrapper to allow Razor to post telemetry via the language server's session. /// internal interface ILanguageServerTelemetryReporterWrapper { void ReportEvent(string name, List> properties); - /// - /// Posts an aggregated measurement. The event must be forwarded intact: its aggregated values live - /// on its instrument, and only TelemetrySession.PostMetricEvent reads them. Flattening it to - /// a name and property bag would discard every measurement. - /// void ReportMetric(TelemetryMetricEvent metricEvent); } diff --git a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs index 0b9ed86b4ab98..f6c19a98afc16 100644 --- a/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs +++ b/src/Razor/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeTelemetryReporter.cs @@ -31,9 +31,6 @@ protected override void Report(TelemetryEvent telemetryEvent) public override void ReportMetric(AggregatingTelemetryLog.TelemetryInstrumentEvent metricEvent) { - // Forwarded intact: flattening to name + properties would discard every measurement. - // Unreachable in this host, which constructs the base reporter without a session, so no - // AggregatingTelemetryLog is created to call it. _reporter?.ReportMetric(metricEvent); } } diff --git a/src/VisualStudio/Core/Def/RoslynActivityLogger.cs b/src/VisualStudio/Core/Def/RoslynActivityLogger.cs index 36ddbfee4a9a0..bb302b0fd064b 100644 --- a/src/VisualStudio/Core/Def/RoslynActivityLogger.cs +++ b/src/VisualStudio/Core/Def/RoslynActivityLogger.cs @@ -21,11 +21,6 @@ namespace Microsoft.VisualStudio.LanguageServices; /// internal static class RoslynActivityLogger { - /// - /// A single sink, composed once at startup, whose contents vary. Adding and removing a - /// mutates this set; when the set is empty the sink reports itself - /// disabled and costs one array-length check per event. - /// public static readonly TraceSourceSink Sink = new(); public static void SetLogger(TraceSource traceSource) diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs index 3884752f98eed..c69dae12fe7d3 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs @@ -122,7 +122,7 @@ public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int blockI return; } - // LogBlockStart swallows a failure from Start, so the scope can legitimately be missing here. + // There might be no start if this sink was enabled or disabled in between. if (!_pendingScopes.TryRemove(blockId, out var scope)) return; diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index 1f6d3427c168a..934a997cbfa98 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -21,23 +21,16 @@ namespace Microsoft.CodeAnalysis.Telemetry; /// The aggregating metric sink for one , backed by VS Telemetry's counter /// and histogram APIs. Measurements accumulate in memory against an instrument and are posted in batches /// by . -/// -/// A host that needs several sessions in one process composes one of these per session behind an -/// that routes between them; nothing here needs to change for that. -/// /// internal sealed class VSMetricSink : IMetricSink, IDisposable { /// - /// Version information which VS Telemetry attaches to our aggregated telemetry, so that Kusto - /// queries can filter to the versions whose shape they understand. + /// Version attached to aggregated telemetry so queries filter by versions they understand. /// private const string MeterVersion = "0.40"; /// - /// The per-session capability this sink needs. Abstracted so that tests can assert exactly how many - /// metric events a flush posts without standing up a real, opted-in - /// (which would try to send). + /// Abstraction for posting telemetry used for testing. /// internal interface IMetricPoster { @@ -89,15 +82,9 @@ private VSMetricSink(IMetricPoster poster) { _poster = poster; - // Owned here so that composing a sink is all a host has to remember. Shutdown paths flush - // explicitly as well, since a host can exit too abruptly for a timer. _ = PostCollectedTelemetryAsync(); } - /// - /// Stops the periodic flush. The cancellation source is deliberately not disposed: the loop reads - /// its token on every iteration, and a disposed source would throw there instead of stopping. - /// public void Dispose() => _flushLoopCancellation.Cancel(); @@ -160,9 +147,7 @@ public void Flush() { var aggregation = pair.Value; // Excludes concurrent Add/Record on this instrument while the metric event is built - // from it and posted. The key stays in the map until the post completes, so a - // concurrent Count/Record finds this aggregation and blocks here rather than building - // a second instrument with the same name. + // from it and posted. lock (aggregation.Lock) { TelemetryMetricEvent metricEvent = aggregation.Instrument switch @@ -184,7 +169,6 @@ public void Flush() private Aggregation? GetOrCreateAggregation(string eventName, string metricName, ReadOnlySpan> tags, bool isCounter) { - // Checked here so that no telemetry object graph is built for an opted-out session. if (!_poster.IsOptedIn) return null; diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs index 4476ea430b8f5..1e4fc5bc5f20b 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs @@ -31,8 +31,7 @@ public sealed class PerfMarginPanel : UserControl public PerfMarginPanel() { - // AddEventSink ignores a sink it already holds, so reopening the tool window cannot register - // the logger twice. It is never unregistered, so the model keeps accumulating while closed. + // Register the event sink on open - duplicate registrations are ignored. _ = RoslynTelemetry.AddEventSink(s_logger); // grid diff --git a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs index 330ee31463736..4314213b0aefc 100644 --- a/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs +++ b/src/Workspaces/Core/Portable/Log/RoslynTelemetry.Workspaces.cs @@ -8,8 +8,7 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// Property names that telemetry consumers depend on by string. Kept as constants so the emitted shape -/// is stable and greppable. +/// Property names that telemetry consumers depend on by string. /// internal static class TelemetryKeys { @@ -28,8 +27,7 @@ internal static partial class RoslynTelemetry /// Posts a discrete event carrying the wall-clock duration of the returned scope, but only if it /// meets or exceeds . Unlike /// this is not aggregated - each occurrence is - /// its own event. Takes ownership of whether or not anything is - /// listening. + /// its own event. /// public static IDisposable? LogBlockTime(FunctionId functionId, KeyValueLogMessage logMessage, int minThresholdMs = -1) { diff --git a/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs b/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs index a518e7af4ce6f..e839e3ed6ed00 100644 --- a/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs +++ b/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs @@ -133,7 +133,7 @@ static void AssertReturnedToPool(Action log) /// /// The duration of a block reaches the event as a delta property, but only for a sink that - /// asked for it. The standalone language server host asks for it; devenv gates it on an option. + /// asked for it. /// [Theory] [InlineData(true)] diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs index 56b562dfc069a..7c9ebd243e4c9 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IEventSink.cs @@ -7,15 +7,12 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// A destination for discrete events and scopes identified by . -/// Implementations decide, via , whether anything is recorded at all; -/// that is where consent (for telemetry sinks) and opt-in enablement (for diagnostic sinks) live. +/// A destination for events reported by . /// internal interface IEventSink { /// - /// Whether this sink will record anything for . Checked before any - /// is constructed, so returning false makes logging allocation-free. + /// Whether this sink will record anything for . /// bool IsEnabled(FunctionId functionId); diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs index b7f7f22972763..662e5bd931d86 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/IMetricSink.cs @@ -11,12 +11,6 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// A destination for aggregated measurements. Implementations accumulate values in memory and post /// them in batches when is called. /// -/// The contract names no telemetry-backend or BCL-metrics type, so it can live in the -/// dependency-minimal shared layer. It is keyed by a plain eventName string so that one -/// implementation can serve both Roslyn and Razor; Roslyn's -to-event-name -/// mapping happens one level up, in . -/// -/// /// The tag parameter mirrors System.Diagnostics.Metrics.Counter<T>.Add, so recording could /// move onto BCL metric instruments without touching any call site. /// diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs index f23489e805edb..380a7f6e2242b 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/Logger.cs @@ -8,8 +8,7 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// Forwarding shim onto , for call sites that have not moved over. New -/// code should call directly. +/// Forwarding shim onto . /// internal static class Logger { diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs index 05221145a3228..0a291dd755b07 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -13,9 +13,7 @@ namespace Microsoft.CodeAnalysis.Internal.Log; internal static partial class RoslynTelemetry { /// - /// The sinks every measurement fans out to. A sink is added once and stays until its registration - /// is disposed. There is one per host today; a host serving several sessions registers a sink that - /// routes between them. + /// The registered each metric fans out to. /// private static ImmutableArray s_metricSinks = []; @@ -31,8 +29,7 @@ public static IDisposable AddMetricSink(IMetricSink sink) } /// - /// Posts all pending aggregated measurements. Called on a timer, at shutdown, and when a logical - /// session ends. + /// Posts all pending aggregated measurements. /// public static void Flush() { diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index ddbd7856b3850..5f6dc5ae469f0 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -10,25 +10,14 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// -/// Roslyn's telemetry entry point. Discrete events and scopes are recorded here and fan out to the -/// host's configured s; aggregated measurements go to its . -/// -/// A host configures this once at startup (see / ). -/// With nothing configured every method is a cheap no-op, which is the state the build server, the -/// CodeStyle packages, and most tests run in. -/// -/// -/// This file is linked into several assemblies, so "configured" means configured in the assembly the -/// caller resolves to. Only the Workspaces copy is wired up by a host; the CodeStyle copies have no -/// sinks by construction. -/// +/// Telemetry entry point. Events / metrics are recorded here and fan out to the respective +/// or . When no sinks are registered calls are +/// cheap no-ops. /// internal static partial class RoslynTelemetry { /// - /// The sinks every event fans out to. A sink is added once and stays until its registration is - /// disposed; turning one off without removing it is that sink's own - /// returning false. + /// The registered each event fans out to. /// private static ImmutableArray s_eventSinks = []; @@ -48,9 +37,6 @@ public static IDisposable AddEventSink(IEventSink sink) return new Registration(() => ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Remove(sink), sink)); } - /// - /// Undoes one or call. - /// private sealed class Registration(Action unregister) : IDisposable { public void Dispose() => unregister(); @@ -59,7 +45,7 @@ private sealed class Registration(Action unregister) : IDisposable /// /// Whether any registered sink wants . Checked before a /// is constructed, so that logging costs nothing when everything is - /// disabled - which is the common case, since most sinks are opt-in diagnostics. + /// disabled. /// private static bool TryGetEnabledSinks(FunctionId functionId, out ImmutableArray sinks) { diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs index 21e1f58c0792c..18c98d9abe12b 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs @@ -9,8 +9,6 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// /// Maps onto the event and property names Roslyn's telemetry backend expects. -/// Sinks receive already-final names, so one sink implementation can serve both Roslyn, whose identity -/// is , and Razor, whose identity is a plain string. /// internal static class TelemetryNaming { From 01952605af50ed5640fac1442aea6234e4b4e636 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 27 Aug 2026 15:04:10 -0700 Subject: [PATCH 25/34] Close the flush race, name the instrument kinds, share the naming A measurement that resolved an aggregation just before a flush retired it landed in the retired one and was never posted. Count and Record now re-check under the aggregation lock that they still hold the live aggregation for the key, and retry if a flush replaced it. The two also collapse into one Update, since the only difference left was which instrument method to call. AggregationKey carries an InstrumentKind rather than a bool named for one of its two values, so the switch that builds the instrument reads as the pair it is. GetMeterName and GetPropertyName move to TelemetryNaming, which is where the vs/ide/vbcs convention already lives; they were the second place it was implemented. Adds a test for the race, driven from IMetricPoster.Post, which is the one point a concurrent Count is guaranteed to be blocked on the aggregation lock. Verified non-vacuous: it fails without the re-check. Validation: Ide.slnf builds clean; LSP telemetry tests 22/22; Workspaces telemetry tests 8/8 on net10.0 and net472. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../VSMetricSinkTests.cs | 65 ++++++++++++- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 93 +++++++++++-------- .../Compiler/Core/Log/TelemetryNaming.cs | 14 +++ 3 files changed, 128 insertions(+), 44 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs index 71c9c8a009c3e..8f238f5df4672 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs @@ -1,10 +1,14 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. #nullable disable +using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Telemetry; using Microsoft.VisualStudio.Telemetry; using Microsoft.VisualStudio.Telemetry.Metrics.Events; @@ -22,6 +26,11 @@ private sealed class RecordingPoster : VSMetricSink.IMetricPoster { public List Posted { get; } = []; + /// + /// Runs inside the aggregation lock a flush holds while posting. + /// + public Action? OnPost { get; set; } + /// /// The telemetry events carried by , captured at post time because /// TelemetryMetricEvent does not expose them. @@ -33,6 +42,7 @@ public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent { Posted.Add(metricEvent); PostedEvents.Add(telemetryEvent); + OnPost?.Invoke(); } } @@ -111,11 +121,11 @@ public void NothingIsRecordedForAnOptedOutSession() [Fact] public void NameDerivationMatchesTheTelemetryConvention() { - Assert.Equal("vs.ide.vbcs.lsp.requestduration.meter", VSMetricSink.GetMeterName("vs/ide/vbcs/lsp/requestduration")); - Assert.Equal("vs.ide.vbcs.lsp.requestduration.server", VSMetricSink.GetPropertyName("vs/ide/vbcs/lsp/requestduration", "server")); + Assert.Equal("vs.ide.vbcs.lsp.requestduration.meter", TelemetryNaming.GetMeterName("vs/ide/vbcs/lsp/requestduration")); + Assert.Equal("vs.ide.vbcs.lsp.requestduration.server", TelemetryNaming.GetPropertyName("vs/ide/vbcs/lsp/requestduration", "server")); // Tag names are lowercased; the event name is already lowercase by construction. - Assert.Equal("vs.ide.vbcs.lsp.requestduration.server", VSMetricSink.GetPropertyName("vs/ide/vbcs/lsp/requestduration", "Server")); + Assert.Equal("vs.ide.vbcs.lsp.requestduration.server", TelemetryNaming.GetPropertyName("vs/ide/vbcs/lsp/requestduration", "Server")); } /// @@ -137,4 +147,51 @@ public void CountersAndDistributionsDoNotShareABucket() Assert.Single(poster.Posted, e => e is TelemetryCounterEvent); Assert.Single(poster.Posted, e => e is TelemetryHistogramEvent); } + + /// + /// A flush posts an aggregation and then retires it, both under that aggregation's lock. A + /// measurement that resolved the same aggregation just before, and is waiting on the lock, must not + /// land in the retired one and be lost. + /// + [Fact] + public void AMeasurementTakenDuringAFlushIsNotDropped() + { + var insideFlush = new ManualResetEventSlim(); + var countBlocked = new ManualResetEventSlim(); + + var poster = new RecordingPoster(); + using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); + + // Post runs while the flush holds the aggregation lock, so it is the point where a concurrent + // Count is guaranteed to be blocked. + poster.OnPost = () => + { + insideFlush.Set(); + countBlocked.Wait(); + }; + + sink.Count("vs/ide/vbcs/test/race", "Count", 1, default); + + var flushing = Task.Run(sink.Flush); + insideFlush.Wait(); + + var counting = Task.Run(() => sink.Count("vs/ide/vbcs/test/race", "Count", 1, default)); + + // Count has resolved the aggregation the flush is posting and is now waiting on its lock. + // There is no way to observe that directly, so give it time to get there before releasing. + Thread.Sleep(100); + countBlocked.Set(); + + flushing.Wait(); + counting.Wait(); + + Assert.Single(poster.Posted); + + // The second measurement must still be pending, not lost with the retired aggregation. + poster.OnPost = null; + poster.Posted.Clear(); + sink.Flush(); + + Assert.Single(poster.Posted); + } } diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index 934a997cbfa98..cf308440b0dbc 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -45,10 +45,16 @@ private sealed class SessionPoster(TelemetrySession session) : IMetricPoster } /// - /// Identifies one aggregation bucket. participates so that the same - /// event and metric name used both ways cannot resolve to an instrument of the wrong type. + /// Identifies one aggregation bucket. participates so that the same event + /// and metric name used both ways cannot resolve to an instrument of the wrong type. /// - private readonly record struct AggregationKey(string EventName, string MetricName, string DimensionKey, bool IsCounter); + private readonly record struct AggregationKey(string EventName, string MetricName, string DimensionKey, InstrumentKind Kind); + + private enum InstrumentKind + { + Counter, + Histogram, + } private sealed class Aggregation(IInstrument instrument, TelemetryEvent telemetryEvent) { @@ -117,24 +123,46 @@ internal readonly struct TestAccessor } public void Count(string eventName, string metricName, long delta, ReadOnlySpan> tags) - { - if (GetOrCreateAggregation(eventName, metricName, tags, isCounter: true) is not { } aggregation) - return; - - lock (aggregation.Lock) - { - ((ICounter)aggregation.Instrument).Add(delta); - } - } + => Update(eventName, metricName, tags, InstrumentKind.Counter, delta); public void Record(string eventName, string metricName, long value, ReadOnlySpan> tags) + => Update(eventName, metricName, tags, InstrumentKind.Histogram, value); + + private void Update(string eventName, string metricName, ReadOnlySpan> tags, InstrumentKind kind, long value) { - if (GetOrCreateAggregation(eventName, metricName, tags, isCounter: false) is not { } aggregation) + if (!_poster.IsOptedIn) return; - lock (aggregation.Lock) + var key = new AggregationKey(eventName, metricName, BuildDimensionKey(tags), kind); + + while (true) { - ((IHistogram)aggregation.Instrument).Record(value); + var aggregation = GetOrCreateAggregation(key, tags); + + lock (aggregation.Lock) + { + // A flush posts an aggregation and then removes it, both under this lock. Retry if that + // happened while we were waiting, so the measurement lands in an aggregation that is + // still going to be posted rather than one already retired. + if (!_aggregations.TryGetValue(key, out var current) || current != aggregation) + continue; + + switch (kind) + { + case InstrumentKind.Counter: + ((ICounter)aggregation.Instrument).Add(value); + break; + + case InstrumentKind.Histogram: + ((IHistogram)aggregation.Instrument).Record(value); + break; + + default: + throw ExceptionUtilities.UnexpectedValue(kind); + } + + return; + } } } @@ -167,13 +195,8 @@ public void Flush() } } - private Aggregation? GetOrCreateAggregation(string eventName, string metricName, ReadOnlySpan> tags, bool isCounter) + private Aggregation GetOrCreateAggregation(AggregationKey key, ReadOnlySpan> tags) { - if (!_poster.IsOptedIn) - return null; - - var key = new AggregationKey(eventName, metricName, BuildDimensionKey(tags), isCounter); - if (_aggregations.TryGetValue(key, out var existing)) return existing; @@ -189,12 +212,15 @@ private Aggregation CreateAggregation(AggregationKey key, KeyValuePair(key.MetricName) - : meter.CreateHistogram(key.MetricName); + IInstrument instrument = key.Kind switch + { + InstrumentKind.Counter => meter.CreateCounter(key.MetricName), + InstrumentKind.Histogram => meter.CreateHistogram(key.MetricName), + _ => throw ExceptionUtilities.UnexpectedValue(key.Kind), + }; return new Aggregation(instrument, telemetryEvent); } @@ -203,22 +229,9 @@ private IMeter GetOrCreateMeter(string eventName) => ImmutableInterlocked.GetOrAdd( ref _meters, eventName, - static (eventName, provider) => provider.CreateMeter(GetMeterName(eventName), version: MeterVersion), + static (eventName, provider) => provider.CreateMeter(TelemetryNaming.GetMeterName(eventName), version: MeterVersion), _meterProvider); - /// - /// Derives the meter name (vs.ide.vbcs.some.operation.meter) from the event name - /// (vs/ide/vbcs/some/operation). - /// - internal static string GetMeterName(string eventName) - => eventName.Replace('/', '.') + ".meter"; - - /// - /// Derives a property name (vs.ide.vbcs.some.operation.tagname) from the event name. - /// - internal static string GetPropertyName(string eventName, string tagName) - => eventName.Replace('/', '.') + "." + tagName.ToLowerInvariant(); - /// /// Builds the bucket discriminator from the tag values, in declaration order, so that measurements /// differing in any dimension aggregate separately. diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs index 18c98d9abe12b..887c951383e9f 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/TelemetryNaming.cs @@ -26,6 +26,20 @@ public static string GetEventName(FunctionId id) public static string GetPropertyName(FunctionId id, string name) => s_propertyMap.GetOrAdd((id, name), key => PropertyPrefix + GetTelemetryName(key.id, separator: '.') + "." + key.name.ToLowerInvariant()); + /// + /// Derives the meter name (vs.ide.vbcs.some.operation.meter) from an event name already + /// produced by (vs/ide/vbcs/some/operation). + /// + public static string GetMeterName(string eventName) + => eventName.Replace('/', '.') + ".meter"; + + /// + /// Derives a property name (vs.ide.vbcs.some.operation.tagname) from an event name already + /// produced by . + /// + public static string GetPropertyName(string eventName, string tagName) + => eventName.Replace('/', '.') + "." + tagName.ToLowerInvariant(); + private static string GetTelemetryName(FunctionId id, char separator) => Enum.GetName(typeof(FunctionId), id)!.Replace('_', separator).ToLowerInvariant(); } From 4a0b8e23125da6598fa43940d01c1ecd51b6c6a4 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 27 Aug 2026 16:52:28 -0700 Subject: [PATCH 26/34] Make the telemetry test accessors static RoslynTelemetry is a static class, so its TestAccessor held no state and RemoveAllSinks touched only statics. CA1822 fires on that, as an error in every project the shared Log folder is link-compiled into -- the CodeStyle and RoslynAnalyzers projects build with stricter analyzer settings than the ones this was developed against. Uses the shape the repo already has for static hosts, an internal static class TestAccessor, as in PathUtilities and LanguageServerExportProviderBuilder. VSMetricSink's accessor had the same shape without tripping the analyzer, since that file is not link-compiled anywhere that treats CA1822 as an error; it moves too rather than leaving two conventions in one feature. Validation: the three reported projects (CodeStyle, Workspaces, BannedApiAnalyzers) build clean, as does Ide.slnf; LSP telemetry tests 22/22 and Workspaces telemetry tests 8/8 on net10.0 and net472. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../LanguageServerRequestTelemetryTests.cs | 2 +- .../VSMetricSinkTests.cs | 12 ++++++------ .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 6 ++---- .../MEF/UseExportProviderAttribute.cs | 2 +- .../Compiler/Core/Log/RoslynTelemetry.cs | 6 ++---- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs index deb796bfac70b..1b9baafa0d2b9 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs @@ -37,7 +37,7 @@ public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent public async Task RealRequestsProduceAggregatedTelemetry() { var poster = new RecordingPoster(); - using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); + using var sink = VSMetricSink.TestAccessor.CreateSink(poster); using var registration = RoslynTelemetry.AddMetricSink(sink); var server = await CreateLanguageServerAsync(); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs index 8f238f5df4672..6c78abf948521 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs @@ -50,7 +50,7 @@ public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent public void RecordedMeasurementsArePostedExactlyOncePerFlush() { var poster = new RecordingPoster(); - using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); + using var sink = VSMetricSink.TestAccessor.CreateSink(poster); sink.Count("vs/ide/vbcs/test/counter", "SucceededCount", 1, default); sink.Count("vs/ide/vbcs/test/counter", "SucceededCount", 1, default); @@ -71,7 +71,7 @@ public void RecordedMeasurementsArePostedExactlyOncePerFlush() public void TagValuesDiscriminateBuckets() { var poster = new RecordingPoster(); - using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); + using var sink = VSMetricSink.TestAccessor.CreateSink(poster); // Same event and metric, different tag values: these must aggregate into separate buckets. sink.Record("vs/ide/vbcs/lsp/requestduration", "RequestDuration", 10, @@ -90,7 +90,7 @@ public void TagValuesDiscriminateBuckets() public void EventAndPropertyNamesUseTheTelemetryConvention() { var poster = new RecordingPoster(); - using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); + using var sink = VSMetricSink.TestAccessor.CreateSink(poster); sink.Count("vs/ide/vbcs/lsp/requestcounter", "SucceededCount", 1, new KeyValuePair[] { new("server", "Roslyn") }); @@ -106,7 +106,7 @@ public void EventAndPropertyNamesUseTheTelemetryConvention() public void NothingIsRecordedForAnOptedOutSession() { var poster = new RecordingPoster { IsOptedIn = false }; - using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); + using var sink = VSMetricSink.TestAccessor.CreateSink(poster); sink.Count("vs/ide/vbcs/test/counter", "SucceededCount", 1, default); sink.Flush(); @@ -136,7 +136,7 @@ public void NameDerivationMatchesTheTelemetryConvention() public void CountersAndDistributionsDoNotShareABucket() { var poster = new RecordingPoster(); - using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); + using var sink = VSMetricSink.TestAccessor.CreateSink(poster); sink.Count("vs/ide/vbcs/test/both", "Value", 1, default); sink.Record("vs/ide/vbcs/test/both", "Value", 42, default); @@ -160,7 +160,7 @@ public void AMeasurementTakenDuringAFlushIsNotDropped() var countBlocked = new ManualResetEventSlim(); var poster = new RecordingPoster(); - using var sink = VSMetricSink.GetTestAccessor().CreateSink(poster); + using var sink = VSMetricSink.TestAccessor.CreateSink(poster); // Post runs while the flush holds the aggregation lock, so it is the point where a concurrent // Count is guaranteed to be blocked. diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index cf308440b0dbc..a998141fb6cb7 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -114,12 +114,10 @@ private async Task PostCollectedTelemetryAsync() } } - internal static TestAccessor GetTestAccessor() => default; - - internal readonly struct TestAccessor + internal static class TestAccessor { /// - public VSMetricSink CreateSink(IMetricPoster poster) => new(poster); + public static VSMetricSink CreateSink(IMetricPoster poster) => new(poster); } public void Count(string eventName, string metricName, long delta, ReadOnlySpan> tags) diff --git a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs index e5e3dd6707f7f..3f2a8e6c25d11 100644 --- a/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs +++ b/src/Workspaces/CoreTestUtilities/MEF/UseExportProviderAttribute.cs @@ -101,7 +101,7 @@ public override void After(MethodInfo? methodUnderTest) // Reset static state variables. _hostServices = null; ExportProviderCache.SetEnabled_OnlyUseExportProviderAttributeCanCall(false); - RoslynTelemetry.GetTestAccessor().RemoveAllSinks(); + RoslynTelemetry.TestAccessor.RemoveAllSinks(); } } diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index 5f6dc5ae469f0..52e30ec2a2565 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -69,14 +69,12 @@ private static void LogToSinks(ImmutableArray sinks, FunctionId func } } - internal static TestAccessor GetTestAccessor() => default; - - internal readonly struct TestAccessor + internal static class TestAccessor { /// /// Unregisters every sink, so that one test cannot leak a sink into the next. /// - public void RemoveAllSinks() + public static void RemoveAllSinks() { ImmutableInterlocked.InterlockedExchange(ref s_eventSinks, []); ImmutableInterlocked.InterlockedExchange(ref s_metricSinks, []); From 6a11d598c845109032e4c5e3e84ad56e474fe661 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 27 Aug 2026 18:46:48 -0700 Subject: [PATCH 27/34] Fix analyzer violations in projects with stricter settings IDE2006: CreateEventSinks put the collection expression on the line after the arrow. CS8632: VSMetricSinkTests and PerformanceLoggersPage are both #nullable disable, so the nullable annotations on OnPost and the three registration fields are not allowed there. Only the test was reported; the options page was found by sweeping the diff for the same pattern. Validation: Microsoft.VisualStudio.LanguageServices, Roslyn.VisualStudio.DiagnosticsWindow and the LSP unit tests build clean; LSP telemetry tests 22/22, Workspaces telemetry tests 8/8 on net10.0 and net472. Ide.slnf has no C# errors -- its only failure is the pre-existing Arcade "RepositoryCommit must be specified" in the integration test harness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../VSMetricSinkTests.cs | 2 +- .../Telemetry/VisualStudioWorkspaceTelemetryService.cs | 3 +-- .../OptionPages/PerformanceLoggersPage.cs | 10 +++++----- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs index 6c78abf948521..95426aaed547d 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs @@ -29,7 +29,7 @@ private sealed class RecordingPoster : VSMetricSink.IMetricPoster /// /// Runs inside the aggregation lock a flush holds while posting. /// - public Action? OnPost { get; set; } + public Action OnPost { get; set; } /// /// The telemetry events carried by , captured at post time because diff --git a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs index aeb85cf29dddd..bc04346412bf7 100644 --- a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs @@ -33,8 +33,7 @@ internal sealed class VisualStudioWorkspaceTelemetryService( private readonly IGlobalOptionService _globalOptions = globalOptions; protected override ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta) - => - [ + => [ CodeMarkerLogger.Instance, RoslynActivityLogger.Sink, TelemetryLogger.Create(telemetrySession, logDelta), diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs index 2df2c914f23a8..0152616cb8745 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -30,9 +30,9 @@ internal sealed class PerformanceLoggersPage : AbstractOptionPage private IThreadingContext _threadingContext; private SolutionServices _workspaceServices; - private static IDisposable? s_etwRegistration; - private static IDisposable? s_traceRegistration; - private static IDisposable? s_outputWindowRegistration; + private static IDisposable s_etwRegistration; + private static IDisposable s_traceRegistration; + private static IDisposable s_outputWindowRegistration; protected override AbstractOptionPageControl CreateOptionPage(IServiceProvider serviceProvider, OptionStore optionStore) { @@ -89,7 +89,7 @@ public static void SetLoggers(IGlobalOptionService globalOptions, IThreadingCont CancellationToken.None).ConfigureAwait(false)); } - static void Register(ref IDisposable? registration, bool enabled, Func create) + static void Register(ref IDisposable registration, bool enabled, Func create) { Interlocked.Exchange(ref registration, enabled ? RoslynTelemetry.AddEventSink(create()) : null)?.Dispose(); } From 9f43ea298f6775c81ab26ab87218858fbae7a0d1 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 27 Aug 2026 18:51:38 -0700 Subject: [PATCH 28/34] Enable nullable in the new language server telemetry tests The project sets enable and 32 of its 34 files honour it -- the two that did not were both added by this change, from a copied file header rather than a need. Removing the suppression means OnPost is annotated for the null it is actually assigned, and the tag arrays state the object? element type the IMetricSink span already uses. ServiceHubServicesTests_Telemetry.cs keeps its suppression: it is a partial of ServiceHubServicesTests, which is suppressed, as are 7 of the 8 files beside it. Validation: the test project builds with no nullable warnings; LSP telemetry tests 22/22. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c5bb045-3aa8-4014-bc0c-d5a968389dee --- .../LanguageServerRequestTelemetryTests.cs | 2 -- .../VSMetricSinkTests.cs | 12 +++++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs index 1b9baafa0d2b9..f26a32929253d 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -#nullable disable - using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Internal.Log; diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs index 95426aaed547d..3b5673c2166f3 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -#nullable disable - using System; using System.Collections.Generic; using System.Threading; @@ -29,7 +27,7 @@ private sealed class RecordingPoster : VSMetricSink.IMetricPoster /// /// Runs inside the aggregation lock a flush holds while posting. /// - public Action OnPost { get; set; } + public Action? OnPost { get; set; } /// /// The telemetry events carried by , captured at post time because @@ -75,11 +73,11 @@ public void TagValuesDiscriminateBuckets() // Same event and metric, different tag values: these must aggregate into separate buckets. sink.Record("vs/ide/vbcs/lsp/requestduration", "RequestDuration", 10, - new KeyValuePair[] { new("server", "Roslyn"), new("method", "textDocument/hover") }); + new KeyValuePair[] { new("server", "Roslyn"), new("method", "textDocument/hover") }); sink.Record("vs/ide/vbcs/lsp/requestduration", "RequestDuration", 20, - new KeyValuePair[] { new("server", "Roslyn"), new("method", "textDocument/completion") }); + new KeyValuePair[] { new("server", "Roslyn"), new("method", "textDocument/completion") }); sink.Record("vs/ide/vbcs/lsp/requestduration", "RequestDuration", 30, - new KeyValuePair[] { new("server", "Roslyn"), new("method", "textDocument/hover") }); + new KeyValuePair[] { new("server", "Roslyn"), new("method", "textDocument/hover") }); sink.Flush(); @@ -93,7 +91,7 @@ public void EventAndPropertyNamesUseTheTelemetryConvention() using var sink = VSMetricSink.TestAccessor.CreateSink(poster); sink.Count("vs/ide/vbcs/lsp/requestcounter", "SucceededCount", 1, - new KeyValuePair[] { new("server", "Roslyn") }); + new KeyValuePair[] { new("server", "Roslyn") }); sink.Flush(); From 50d3af4dda330ae5bc8a2d85f7b8ce9939888866 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Mon, 31 Aug 2026 15:47:27 -0700 Subject: [PATCH 29/34] only allow sink to be registered once --- .../PerfMargin/PerfMarginPanel.cs | 7 ++++-- .../Core/Log/RoslynTelemetry.Metrics.cs | 10 ++++---- .../Compiler/Core/Log/RoslynTelemetry.cs | 24 ++++++++++++++----- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs index 1e4fc5bc5f20b..3703f91a9bb25 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs @@ -29,11 +29,14 @@ public sealed class PerfMarginPanel : UserControl private ListView _detailsListView; private bool _stopTimer; - public PerfMarginPanel() + static PerfMarginPanel() { - // Register the event sink on open - duplicate registrations are ignored. + // Keep collecting into the process-wide model while the tool window is closed. _ = RoslynTelemetry.AddEventSink(s_logger); + } + public PerfMarginPanel() + { // grid _mainGrid = new Grid(); _mainGrid.ColumnDefinitions.Add(new ColumnDefinition()); diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs index 0a291dd755b07..f453c3053a081 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs @@ -18,14 +18,14 @@ internal static partial class RoslynTelemetry private static ImmutableArray s_metricSinks = []; /// - /// Registers to receive measurements, ignoring it if it is already - /// registered. Dispose the result to unregister it; a host that keeps its sink for the life of the - /// process can simply never dispose. + /// Registers to receive measurements. A sink instance may have only one + /// active registration. Dispose the result to unregister it; a host that keeps its sink for the + /// life of the process can simply never dispose. /// public static IDisposable AddMetricSink(IMetricSink sink) { - ImmutableInterlocked.Update(ref s_metricSinks, static (sinks, sink) => sinks.Contains(sink) ? sinks : sinks.Add(sink), sink); - return new Registration(() => ImmutableInterlocked.Update(ref s_metricSinks, static (sinks, sink) => sinks.Remove(sink), sink)); + ImmutableInterlocked.Update(ref s_metricSinks, static (sinks, sink) => AddSink(sinks, sink), sink); + return new Registration(() => ImmutableInterlocked.Update(ref s_metricSinks, static (sinks, sink) => sinks.Remove(sink, ReferenceEqualityComparer.Instance), sink)); } /// diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs index 52e30ec2a2565..d31da3d253cd1 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.cs @@ -27,19 +27,31 @@ internal static partial class RoslynTelemetry private static int s_lastUniqueBlockId; /// - /// Registers to receive events, ignoring it if it is already registered. - /// Dispose the result to unregister it; a host that keeps its sinks for the life of the process can - /// simply never dispose. + /// Registers to receive events. A sink instance may have only one active + /// registration. Dispose the result to unregister it; a host that keeps its sinks for the life of + /// the process can simply never dispose. /// public static IDisposable AddEventSink(IEventSink sink) { - ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Contains(sink) ? sinks : sinks.Add(sink), sink); - return new Registration(() => ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Remove(sink), sink)); + ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => AddSink(sinks, sink), sink); + return new Registration(() => ImmutableInterlocked.Update(ref s_eventSinks, static (sinks, sink) => sinks.Remove(sink, ReferenceEqualityComparer.Instance), sink)); + } + + private static ImmutableArray AddSink(ImmutableArray sinks, TSink sink) + where TSink : class + { + foreach (var registeredSink in sinks) + Contract.ThrowIfTrue(ReferenceEquals(registeredSink, sink), "The sink instance is already registered."); + + return sinks.Add(sink); } private sealed class Registration(Action unregister) : IDisposable { - public void Dispose() => unregister(); + private Action? _unregister = unregister; + + public void Dispose() + => Interlocked.Exchange(ref _unregister, null)?.Invoke(); } /// From 0397ff87fe4adb76792f7f9145f189f54e38aeea Mon Sep 17 00:00:00 2001 From: David Barbet Date: Mon, 31 Aug 2026 15:54:31 -0700 Subject: [PATCH 30/34] sort --- .../Compiler/Core/CompilerExtensions.projitems | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems index e27a74ce6731d..5da7d4b480508 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems @@ -335,12 +335,12 @@ + + + - - - From 1f2ddb565a81507429d2f13f94b60f6eb6f25612 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 2 Sep 2026 12:21:42 -0700 Subject: [PATCH 31/34] Address telemetry consolidation review feedback Rename event sink implementations, simplify language server telemetry ownership, make the flush race test deterministic, and add span metric overload coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/instructions/IDE.instructions.md | 13 ++++ .github/memory/FILE_MAP.md | 4 +- .../LanguageServerRequestTelemetryTests.cs | 4 +- .../VSMetricSinkTests.cs | 23 +++--- .../Program.cs | 20 ++--- .../Telemetry/LanguageServerTelemetry.cs | 2 +- .../Core/Def/RoslynActivityLogger.cs | 76 ------------------- ...MarkerLogger.cs => CodeMarkerEventSink.cs} | 9 ++- .../{FileLogger.cs => FileEventSink.cs} | 6 +- ...lemetryLogger.cs => TelemetryEventSink.cs} | 6 +- .../VisualStudioWorkspaceTelemetryService.cs | 8 +- .../Core/Def/TraceSourceEventSink.cs | 65 ++++++++++++++++ .../Services/ServiceHubServicesTests.cs | 4 +- .../ServiceHubServicesTests_Telemetry.cs | 2 +- ...ndowLogger.cs => OutputWindowEventSink.cs} | 4 +- .../OptionPages/PerformanceLoggersPage.cs | 10 +-- ...vityLogger.cs => PerfActivityEventSink.cs} | 6 +- .../PerfMargin/PerfMarginPanel.cs | 4 +- .../Log/{EtwLogger.cs => EtwEventSink.cs} | 4 +- .../Log/{TraceLogger.cs => TraceEventSink.cs} | 2 +- .../CoreTest/Log/RoslynTelemetryTests.cs | 37 ++++++++- ...tryLogger.cs => TestTelemetryEventSink.cs} | 4 +- .../RemoteProcessTelemetryService.cs | 4 +- .../RemoteWorkspaceTelemetryService.cs | 2 +- ...gerTests.cs => TelemetryEventSinkTests.cs} | 8 +- .../Core/CompilerExtensions.projitems | 4 +- ...ogBlock.cs => RoslynTelemetry_LogBlock.cs} | 2 +- ....Metrics.cs => RoslynTelemetry_Metrics.cs} | 13 +++- 28 files changed, 197 insertions(+), 149 deletions(-) delete mode 100644 src/VisualStudio/Core/Def/RoslynActivityLogger.cs rename src/VisualStudio/Core/Def/Telemetry/{CodeMarkerLogger.cs => CodeMarkerEventSink.cs} (97%) rename src/VisualStudio/Core/Def/Telemetry/{FileLogger.cs => FileEventSink.cs} (95%) rename src/VisualStudio/Core/Def/Telemetry/Shared/{TelemetryLogger.cs => TelemetryEventSink.cs} (97%) create mode 100644 src/VisualStudio/Core/Def/TraceSourceEventSink.cs rename src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/{OutputWindowLogger.cs => OutputWindowEventSink.cs} (97%) rename src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/{PerfEventActivityLogger.cs => PerfActivityEventSink.cs} (86%) rename src/Workspaces/Core/Portable/Log/{EtwLogger.cs => EtwEventSink.cs} (93%) rename src/Workspaces/Core/Portable/Log/{TraceLogger.cs => TraceEventSink.cs} (94%) rename src/Workspaces/CoreTestUtilities/Logging/{TestTelemetryLogger.cs => TestTelemetryEventSink.cs} (92%) rename src/Workspaces/Remote/ServiceHubTest/{TelemetryLoggerTests.cs => TelemetryEventSinkTests.cs} (94%) rename src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/{RoslynTelemetry.LogBlock.cs => RoslynTelemetry_LogBlock.cs} (96%) rename src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/{RoslynTelemetry.Metrics.cs => RoslynTelemetry_Metrics.cs} (89%) diff --git a/.github/instructions/IDE.instructions.md b/.github/instructions/IDE.instructions.md index f6a1e030f6da1..d5cd001bf5928 100644 --- a/.github/instructions/IDE.instructions.md +++ b/.github/instructions/IDE.instructions.md @@ -101,3 +101,16 @@ var methodDecl = generator.MethodDeclaration("MyMethod", ...); - **ImportingConstructor must be marked `[Obsolete]`** with `MefConstruction.ImportingConstructorMessage` - **Language services must be exported with a specific language name** — don't use generic exports for both C#/VB - **Workspace changes must use immutable updates** — `Workspace.SetCurrentSolution()` + +## Telemetry + +- The shared entry point and sink contracts live under + `src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/`: `RoslynTelemetry`, + `IEventSink`, and `IMetricSink`. +- Name concrete `IEventSink` implementations with the `EventSink` suffix. Visual Studio implementations + live under `src/VisualStudio/Core/Def/Telemetry/`; `TelemetryEventSink` and `VSMetricSink` are source-linked + into the Language Server and ServiceHub hosts. +- Name `RoslynTelemetry` partial files with an underscore, such as + `RoslynTelemetry_Metrics.cs` and `RoslynTelemetry_LogBlock.cs`. +- Fixed-arity metric overloads avoid allocating tag arrays. Dynamic-tag span overloads use + `OverloadResolutionPriorityAttribute` so target-typed single-tag calls select the fixed-arity overload. diff --git a/.github/memory/FILE_MAP.md b/.github/memory/FILE_MAP.md index 6dec7aa99ed1f..47ba8fe0ed2cf 100644 --- a/.github/memory/FILE_MAP.md +++ b/.github/memory/FILE_MAP.md @@ -19,11 +19,11 @@ This file is a **top-level map only**. For per-area directory detail, read the m | `Dependencies/` | compiler | High-performance pooled collections & threading. | | `ExpressionEvaluator/` | compiler | Debugger expression evaluator. | | `Tools/` | compiler | Compiler and infrastructure tooling (including `dotnet-roslyn-tools`) and benchmark harnesses. | -| `Workspaces/` | ide | Solution/Project/Document model, MSBuild loading, Remote (OOP). | +| `Workspaces/` | ide | Solution/Project/Document model, MSBuild loading, Remote (OOP), and the shared `RoslynTelemetry` entry point/sink contracts. | | `Features/`, `EditorFeatures/` | ide | IDE feature logic and editor integration. | | `Analyzers/`, `CodeStyle/` | ide | IDE0xxx code-style analyzers & fixes. | | `LanguageServer/` | ide | LSP server. | -| `VisualStudio/` | ide | VS language services & UI. | +| `VisualStudio/` | ide | VS language services, UI, and Visual Studio telemetry sink implementations shared with other hosts. | | `Razor/src/` | razor | Razor compiler + tooling (own sub-tree layout). | | `Scripting/`, `Interactive/` | — | C#/VB scripting engine and REPL. | | `RoslynAnalyzers/` | — | Shipping `Microsoft.CodeAnalysis.*` analyzer packages. | diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs index f26a32929253d..62cc83231ae08 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerRequestTelemetryTests.cs @@ -51,7 +51,9 @@ public async Task RealRequestsProduceAggregatedTelemetry() var durations = poster.PostedEvents.FindAll(e => e.Name == "vs/ide/vbcs/lsp/requestduration"); Assert.Contains(durations, e => Equals(e.Properties["vs.ide.vbcs.lsp.requestduration.method"], Methods.InitializeName)); Assert.Contains(durations, e => Equals(e.Properties["vs.ide.vbcs.lsp.requestduration.method"], Methods.InitializedName)); - Assert.All(durations, e => Assert.Equal("CSharpVisualBasicLanguageServerFactory", e.Properties["vs.ide.vbcs.lsp.requestduration.server"])); + Assert.All(durations, e => Assert.Equal( + WellKnownLspServerKinds.CSharpVisualBasicLspServer.ToTelemetryString(), + e.Properties["vs.ide.vbcs.lsp.requestduration.server"])); var counters = poster.PostedEvents.FindAll(e => e.Name == "vs/ide/vbcs/lsp/requestcounter"); Assert.Contains(counters, e => Equals(e.Properties["vs.ide.vbcs.lsp.requestcounter.method"], Methods.InitializeName)); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs index 3b5673c2166f3..ad4086c8333f2 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs @@ -154,8 +154,8 @@ public void CountersAndDistributionsDoNotShareABucket() [Fact] public void AMeasurementTakenDuringAFlushIsNotDropped() { - var insideFlush = new ManualResetEventSlim(); - var countBlocked = new ManualResetEventSlim(); + using var insideFlush = new ManualResetEventSlim(); + using var releaseFlush = new ManualResetEventSlim(); var poster = new RecordingPoster(); using var sink = VSMetricSink.TestAccessor.CreateSink(poster); @@ -165,7 +165,7 @@ public void AMeasurementTakenDuringAFlushIsNotDropped() poster.OnPost = () => { insideFlush.Set(); - countBlocked.Wait(); + releaseFlush.Wait(); }; sink.Count("vs/ide/vbcs/test/race", "Count", 1, default); @@ -173,16 +173,21 @@ public void AMeasurementTakenDuringAFlushIsNotDropped() var flushing = Task.Run(sink.Flush); insideFlush.Wait(); - var counting = Task.Run(() => sink.Count("vs/ide/vbcs/test/race", "Count", 1, default)); + var counting = new Thread(() => sink.Count("vs/ide/vbcs/test/race", "Count", 1, default)); + counting.Start(); - // Count has resolved the aggregation the flush is posting and is now waiting on its lock. - // There is no way to observe that directly, so give it time to get there before releasing. - Thread.Sleep(100); - countBlocked.Set(); + // The counting thread has resolved the aggregation being posted once it blocks on its lock. + var countWasBlocked = SpinWait.SpinUntil( + () => (counting.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(10)); + + releaseFlush.Set(); flushing.Wait(); - counting.Wait(); + var countCompleted = counting.Join(TimeSpan.FromSeconds(10)); + Assert.True(countWasBlocked); + Assert.True(countCompleted); Assert.Single(poster.Posted); // The second measurement must still be pending, not lost with the retired aggregation. diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs index 7e1c92b41a552..61c2b693de14f 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Program.cs @@ -129,10 +129,11 @@ static async Task RunAsync(ServerConfiguration serverConfiguration, Cancell } var telemetryLevel = LanguageServerTelemetry.GetTelemetryLevel(serverConfiguration); - var telemetryService = telemetryLevel is not null - ? exportProvider.GetExportedValue() - : null; - telemetryService?.InitializeSession(telemetryLevel!, serverConfiguration.SessionId, isDefaultSession: true); + if (telemetryLevel is not null) + { + var telemetryService = exportProvider.GetExportedValue(); + telemetryService.InitializeSession(telemetryLevel, serverConfiguration.SessionId, isDefaultSession: true); + } // Build the connection source for the configured mode. Single-server mode (stdio / connect-out pipe) yields // exactly one connection; daemon mode accepts many and manages its own idle timeout. Both run through the same @@ -189,16 +190,9 @@ serverConfiguration.ClientProcessId is int clientProcessId && logger.LogInformation("Language server initialized"); RoslynLog.Logger.Log(RoslynLog.FunctionId.VSCode_LanguageServer_Started, logLevel: RoslynLog.LogLevel.Information); - try - { - using (connectionSource as IDisposable) - { - await connectionManager.RunAsync(connectionSource, exportProvider, typeRefResolver, logger, cancellationToken); - } - } - finally + using (connectionSource as IDisposable) { - telemetryService?.Dispose(); + await connectionManager.RunAsync(connectionSource, exportProvider, typeRefResolver, logger, cancellationToken); } return ServerExitCodes.Success; diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs index 63758bdd7d74b..ee6b51999b9fa 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs @@ -91,7 +91,7 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD var metricSink = new VSMetricSink(session); _registrations = [ - RoslynTelemetry.AddEventSink(TelemetryLogger.Create(session, logDelta: true)), + RoslynTelemetry.AddEventSink(TelemetryEventSink.Create(session, logDelta: true)), RoslynTelemetry.AddMetricSink(metricSink), metricSink, ]; diff --git a/src/VisualStudio/Core/Def/RoslynActivityLogger.cs b/src/VisualStudio/Core/Def/RoslynActivityLogger.cs deleted file mode 100644 index bb302b0fd064b..0000000000000 --- a/src/VisualStudio/Core/Def/RoslynActivityLogger.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Immutable; -using System.Diagnostics; -using System.Threading; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Internal.Log; - -namespace Microsoft.VisualStudio.LanguageServices; - -/// -/// Let people to inject to monitor Roslyn activity -/// -/// Here, we don't technically use TraceSource as it is meant to be used. but just as an easy -/// way to log data to listeners. -/// -/// this also involves creating string, boxing and etc. so, perf wise, it will impact VS quite a bit. -/// this also won't collect trace from Roslyn OOP for now. only in proc activity -/// -internal static class RoslynActivityLogger -{ - public static readonly TraceSourceSink Sink = new(); - - public static void SetLogger(TraceSource traceSource) - { - Contract.ThrowIfNull(traceSource); - Sink.Add(traceSource); - } - - public static void RemoveLogger(TraceSource traceSource) - { - Contract.ThrowIfNull(traceSource); - Sink.Remove(traceSource); - } - - internal sealed class TraceSourceSink : IEventSink - { - private const int LogEventId = 0; - private const int StartEventId = 1; - private const int EndEventId = 2; - - private ImmutableArray _traceSources = []; - - public void Add(TraceSource traceSource) - => ImmutableInterlocked.Update(ref _traceSources, static (sources, source) => sources.Contains(source) ? sources : sources.Add(source), traceSource); - - public void Remove(TraceSource traceSource) - => ImmutableInterlocked.Update(ref _traceSources, static (sources, source) => sources.Remove(source), traceSource); - - public bool IsEnabled(FunctionId functionId) - { - // we log every roslyn activity, but only while someone is listening - return !_traceSources.IsEmpty; - } - - public void Log(FunctionId functionId, LogMessage logMessage) - { - foreach (var traceSource in _traceSources) - traceSource.TraceData(TraceEventType.Verbose, LogEventId, functionId.Convert(), logMessage.GetMessage()); - } - - public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken) - { - foreach (var traceSource in _traceSources) - traceSource.TraceData(TraceEventType.Verbose, StartEventId, functionId.Convert(), uniquePairId); - } - - public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken) - { - foreach (var traceSource in _traceSources) - traceSource.TraceData(TraceEventType.Verbose, EndEventId, functionId.Convert(), uniquePairId, cancellationToken.IsCancellationRequested, delta, logMessage.GetMessage()); - } - } -} diff --git a/src/VisualStudio/Core/Def/Telemetry/CodeMarkerLogger.cs b/src/VisualStudio/Core/Def/Telemetry/CodeMarkerEventSink.cs similarity index 97% rename from src/VisualStudio/Core/Def/Telemetry/CodeMarkerLogger.cs rename to src/VisualStudio/Core/Def/Telemetry/CodeMarkerEventSink.cs index ea838b7e2209b..6f4315f343266 100644 --- a/src/VisualStudio/Core/Def/Telemetry/CodeMarkerLogger.cs +++ b/src/VisualStudio/Core/Def/Telemetry/CodeMarkerEventSink.cs @@ -11,9 +11,12 @@ namespace Microsoft.VisualStudio.LanguageServices.Telemetry; -internal sealed class CodeMarkerLogger : IEventSink +/// +/// Publishes selected Roslyn events as Visual Studio code markers. +/// +internal sealed class CodeMarkerEventSink : IEventSink { - public static readonly CodeMarkerLogger Instance = new(); + public static readonly CodeMarkerEventSink Instance = new(); private static readonly Dictionary>> s_blockMap = new() @@ -125,7 +128,7 @@ private static readonly Dictionary> s_map private static Func, CodeMarkerId> s_startGetter => t => t.Item1; private static Func, CodeMarkerId> s_endGetter => t => t.Item2; - private CodeMarkerLogger() + private CodeMarkerEventSink() { } diff --git a/src/VisualStudio/Core/Def/Telemetry/FileLogger.cs b/src/VisualStudio/Core/Def/Telemetry/FileEventSink.cs similarity index 95% rename from src/VisualStudio/Core/Def/Telemetry/FileLogger.cs rename to src/VisualStudio/Core/Def/Telemetry/FileEventSink.cs index 0282cbafd7e9c..cf7230567edfb 100644 --- a/src/VisualStudio/Core/Def/Telemetry/FileLogger.cs +++ b/src/VisualStudio/Core/Def/Telemetry/FileEventSink.cs @@ -22,9 +22,9 @@ namespace Microsoft.VisualStudio.LanguageServices.Telemetry; /// -/// A logger that publishes events to a log file. +/// An event sink that publishes events to a log file. /// -internal sealed class FileLogger : IEventSink +internal sealed class FileEventSink : IEventSink { private readonly string _logFilePath; private bool _enabled; @@ -34,7 +34,7 @@ internal sealed class FileLogger : IEventSink /// private readonly AsyncBatchingWorkQueue<(FunctionId functionId, string message)> _workQueue; - public FileLogger(IGlobalOptionService optionService, IThreadingContext threadingContext) + public FileEventSink(IGlobalOptionService optionService, IThreadingContext threadingContext) { _logFilePath = Path.Combine(Path.GetTempPath(), "Roslyn", "Telemetry", GetLogFileName()); _workQueue = new( diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryEventSink.cs similarity index 97% rename from src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs rename to src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryEventSink.cs index c69dae12fe7d3..aabc13645f18e 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryLogger.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/TelemetryEventSink.cs @@ -14,9 +14,9 @@ namespace Microsoft.CodeAnalysis.Telemetry; -internal abstract class TelemetryLogger : IEventSink +internal abstract class TelemetryEventSink : IEventSink { - private sealed class Implementation : TelemetryLogger + private sealed class Implementation : TelemetryEventSink { private readonly TelemetrySession _session; @@ -68,7 +68,7 @@ protected override void End(object scope, TelemetryResult result) protected abstract bool LogDelta { get; } - public static TelemetryLogger Create(TelemetrySession session, bool logDelta) + public static TelemetryEventSink Create(TelemetrySession session, bool logDelta) => Implementation.Create(session, logDelta); public abstract bool IsEnabled(FunctionId functionId); diff --git a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs index bc04346412bf7..19a7249092132 100644 --- a/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs +++ b/src/VisualStudio/Core/Def/Telemetry/VisualStudioWorkspaceTelemetryService.cs @@ -34,10 +34,10 @@ internal sealed class VisualStudioWorkspaceTelemetryService( protected override ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta) => [ - CodeMarkerLogger.Instance, - RoslynActivityLogger.Sink, - TelemetryLogger.Create(telemetrySession, logDelta), - new FileLogger(_globalOptions, _threadingContext), + CodeMarkerEventSink.Instance, + TraceSourceEventSink.Instance, + TelemetryEventSink.Create(telemetrySession, logDelta), + new FileEventSink(_globalOptions, _threadingContext), ]; protected override void TelemetrySessionInitialized() diff --git a/src/VisualStudio/Core/Def/TraceSourceEventSink.cs b/src/VisualStudio/Core/Def/TraceSourceEventSink.cs new file mode 100644 index 0000000000000..e5c61bb6515ef --- /dev/null +++ b/src/VisualStudio/Core/Def/TraceSourceEventSink.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Immutable; +using System.Diagnostics; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Internal.Log; + +namespace Microsoft.VisualStudio.LanguageServices; + +/// +/// Allows instances to monitor in-process Roslyn activity. +/// +/// This involves creating strings and boxing, so it is enabled only while a trace source is registered. +/// It does not collect activity from the out-of-process service. +/// +internal sealed class TraceSourceEventSink : IEventSink +{ + public static readonly TraceSourceEventSink Instance = new(); + + private const int LogEventId = 0; + private const int StartEventId = 1; + private const int EndEventId = 2; + + private ImmutableArray _traceSources = []; + + private TraceSourceEventSink() + { + } + + public void Add(TraceSource traceSource) + { + Contract.ThrowIfNull(traceSource); + ImmutableInterlocked.Update(ref _traceSources, static (sources, source) => sources.Contains(source) ? sources : sources.Add(source), traceSource); + } + + public void Remove(TraceSource traceSource) + { + Contract.ThrowIfNull(traceSource); + ImmutableInterlocked.Update(ref _traceSources, static (sources, source) => sources.Remove(source), traceSource); + } + + public bool IsEnabled(FunctionId functionId) + => !_traceSources.IsEmpty; + + public void Log(FunctionId functionId, LogMessage logMessage) + { + foreach (var traceSource in _traceSources) + traceSource.TraceData(TraceEventType.Verbose, LogEventId, functionId.Convert(), logMessage.GetMessage()); + } + + public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken) + { + foreach (var traceSource in _traceSources) + traceSource.TraceData(TraceEventType.Verbose, StartEventId, functionId.Convert(), uniquePairId); + } + + public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken) + { + foreach (var traceSource in _traceSources) + traceSource.TraceData(TraceEventType.Verbose, EndEventId, functionId.Convert(), uniquePairId, cancellationToken.IsCancellationRequested, delta, logMessage.GetMessage()); + } +} diff --git a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs index af765a8798e17..1d433ee495a4b 100644 --- a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs +++ b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests.cs @@ -1948,9 +1948,9 @@ void M() Assert.Equal(1, result.CodeFixAnalysis.DiagnosticIdToCount["CS8300"]); Assert.Equal("CSharp.ConflictMarkerResolution.CSharpResolveConflictMarkerCodeFixProvider", result.CodeFixAnalysis.DiagnosticIdToProviderName["CS8300"].Single()); - var logger = new TestTelemetryLogger(); + var logger = new TestTelemetryEventSink(); using var _ = RoslynTelemetry.AddEventSink(logger); - TestTelemetryLogger.TestScope scope; + TestTelemetryEventSink.TestScope scope; using (CopilotChangeAnalysisUtilities.LogCopilotChangeAnalysis("TestCode", accepted: true, "TestProposalId", result, CancellationToken.None)) { scope = logger.OpenedScopes.Single(); diff --git a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs index 04a3fdb032763..5068de1d1a073 100644 --- a/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs +++ b/src/VisualStudio/Core/Test.Next/Services/ServiceHubServicesTests_Telemetry.cs @@ -30,7 +30,7 @@ public async Task TestRemoteProcessEnableLogging() Assert.False(AnythingIsListening(FunctionId.TestEvent_NotUsed)); - Assert.True(await SetRemoteLoggingAsync([nameof(TraceLogger)], [FunctionId.TestEvent_NotUsed])); + Assert.True(await SetRemoteLoggingAsync([nameof(TraceEventSink)], [FunctionId.TestEvent_NotUsed])); Assert.True(AnythingIsListening(FunctionId.TestEvent_NotUsed)); // The sink was built with a predicate covering only the requested ids. diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowEventSink.cs similarity index 97% rename from src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs rename to src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowEventSink.cs index 9938e092a9b5a..812097d913cf4 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowLogger.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/Loggers/OutputWindowEventSink.cs @@ -18,11 +18,11 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// /// Implementation of that output to output window /// -internal sealed class OutputWindowLogger : IEventSink +internal sealed class OutputWindowEventSink : IEventSink { private readonly Func _isEnabledPredicate; - public OutputWindowLogger(Func isEnabledPredicate) + public OutputWindowEventSink(Func isEnabledPredicate) { _isEnabledPredicate = isEnabledPredicate; } diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs index 0152616cb8745..f7e63341c3bcd 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/OptionPages/PerformanceLoggersPage.cs @@ -68,9 +68,9 @@ public static void SetLoggers(IGlobalOptionService globalOptions, IThreadingCont // These sinks exist only for this page, so each is registered while enabled and unregistered // when not. isEnabled is a snapshot of the per-FunctionId options, which is why a fresh sink is // built on every apply. - Register(ref s_etwRegistration, etwEnabled, () => new EtwLogger(isEnabled)); - Register(ref s_traceRegistration, traceEnabled, () => new TraceLogger(isEnabled)); - Register(ref s_outputWindowRegistration, outputWindowEnabled, () => new OutputWindowLogger(isEnabled)); + Register(ref s_etwRegistration, etwEnabled, () => new EtwEventSink(isEnabled)); + Register(ref s_traceRegistration, traceEnabled, () => new TraceEventSink(isEnabled)); + Register(ref s_outputWindowRegistration, outputWindowEnabled, () => new OutputWindowEventSink(isEnabled)); // update loggers in remote process var client = threadingContext.JoinableTaskFactory.Run(() => RemoteHostClient.TryGetClientAsync(workspaceServices, CancellationToken.None)); @@ -78,9 +78,9 @@ public static void SetLoggers(IGlobalOptionService globalOptions, IThreadingCont { var loggerTypeNames = ImmutableArray.Empty; if (etwEnabled) - loggerTypeNames = loggerTypeNames.Add(nameof(EtwLogger)); + loggerTypeNames = loggerTypeNames.Add(nameof(EtwEventSink)); if (traceEnabled) - loggerTypeNames = loggerTypeNames.Add(nameof(TraceLogger)); + loggerTypeNames = loggerTypeNames.Add(nameof(TraceEventSink)); var functionIds = Enum.GetValues().WhereAsArray(isEnabled); diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfEventActivityLogger.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfActivityEventSink.cs similarity index 86% rename from src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfEventActivityLogger.cs rename to src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfActivityEventSink.cs index 3a70c2939c8be..43d22dbe1e903 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfEventActivityLogger.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfActivityEventSink.cs @@ -7,15 +7,15 @@ namespace Roslyn.Hosting.Diagnostics.PerfMargin; -// A slightly modified version of Roslyn.Services.Internal.Log.EtwLogger. +// A slightly modified version of EtwEventSink. // This version updates the DataModel whenever an operations starts or stops. There // isn't an efficient way to listen to ETW events within the same process unless // running as admin, so we need to add our logic to the logger instead. -internal sealed class PerfEventActivityLogger : IEventSink +internal sealed class PerfActivityEventSink : IEventSink { private readonly DataModel _model; - public PerfEventActivityLogger(DataModel model) + public PerfActivityEventSink(DataModel model) { _model = model; } diff --git a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs index 3703f91a9bb25..674866d1ef9e1 100644 --- a/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs +++ b/src/VisualStudio/VisualStudioDiagnosticsToolWindow/PerfMargin/PerfMarginPanel.cs @@ -18,7 +18,7 @@ namespace Roslyn.Hosting.Diagnostics.PerfMargin; public sealed class PerfMarginPanel : UserControl { private static readonly DataModel s_model = new(); - private static readonly PerfEventActivityLogger s_logger = new(s_model); + private static readonly PerfActivityEventSink s_sink = new(s_model); private readonly ListView _mainListView; private readonly Grid _mainGrid; @@ -32,7 +32,7 @@ public sealed class PerfMarginPanel : UserControl static PerfMarginPanel() { // Keep collecting into the process-wide model while the tool window is closed. - _ = RoslynTelemetry.AddEventSink(s_logger); + _ = RoslynTelemetry.AddEventSink(s_sink); } public PerfMarginPanel() diff --git a/src/Workspaces/Core/Portable/Log/EtwLogger.cs b/src/Workspaces/Core/Portable/Log/EtwEventSink.cs similarity index 93% rename from src/Workspaces/Core/Portable/Log/EtwLogger.cs rename to src/Workspaces/Core/Portable/Log/EtwEventSink.cs index a452ef848e998..eb31f4201098b 100644 --- a/src/Workspaces/Core/Portable/Log/EtwLogger.cs +++ b/src/Workspaces/Core/Portable/Log/EtwEventSink.cs @@ -12,9 +12,9 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// A sink that publishes events to ETW using an EventSource. Opt-in per , via /// Tools -> Options -> Performance Loggers. /// -internal sealed class EtwLogger(Func isEnabledPredicate) : IEventSink +internal sealed class EtwEventSink(Func isEnabledPredicate) : IEventSink { - // Due to ETW specifics, RoslynEventSource.Instance needs to be initialized during EtwLogger construction + // Due to ETW specifics, RoslynEventSource.Instance needs to be initialized during EtwEventSink construction // so that we can enable the listeners synchronously before any events are logged. private readonly RoslynEventSource _source = RoslynEventSource.Instance; diff --git a/src/Workspaces/Core/Portable/Log/TraceLogger.cs b/src/Workspaces/Core/Portable/Log/TraceEventSink.cs similarity index 94% rename from src/Workspaces/Core/Portable/Log/TraceLogger.cs rename to src/Workspaces/Core/Portable/Log/TraceEventSink.cs index d2ebea6bc61d4..cc45e29909222 100644 --- a/src/Workspaces/Core/Portable/Log/TraceLogger.cs +++ b/src/Workspaces/Core/Portable/Log/TraceEventSink.cs @@ -12,7 +12,7 @@ namespace Microsoft.CodeAnalysis.Internal.Log; /// Implementation of that produces timing debug output. Opt-in: registered by /// the Performance Loggers options page while enabled, and unregistered when not. /// -internal sealed class TraceLogger(Func isEnabledPredicate) : IEventSink +internal sealed class TraceEventSink(Func isEnabledPredicate) : IEventSink { public bool IsEnabled(FunctionId functionId) => isEnabledPredicate(functionId); diff --git a/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs b/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs index e839e3ed6ed00..56eee44944859 100644 --- a/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs +++ b/src/Workspaces/CoreTest/Log/RoslynTelemetryTests.cs @@ -35,6 +35,22 @@ public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int unique => Events.Add(("End", uniquePairId)); } + private sealed class RecordingMetricSink : IMetricSink + { + public List CounterTagCounts { get; } = []; + public List DistributionTagCounts { get; } = []; + + public void Count(string eventName, string metricName, long delta, ReadOnlySpan> tags) + => CounterTagCounts.Add(tags.Length); + + public void Record(string eventName, string metricName, long value, ReadOnlySpan> tags) + => DistributionTagCounts.Add(tags.Length); + + public void Flush() + { + } + } + [Fact] public void BlockStartAndEndAreDeliveredAsAPair() { @@ -97,6 +113,23 @@ public void DisposingARegistrationUnregistersTheSink() registration.Dispose(); } + [Fact] + public void MetricOverloadsAcceptSingleAndDynamicTags() + { + var sink = new RecordingMetricSink(); + using var _ = RoslynTelemetry.AddMetricSink(sink); + + RoslynTelemetry.Count(FunctionId.TestEvent_NotUsed, "Count", 1, new("kind", "single")); + RoslynTelemetry.Record(FunctionId.TestEvent_NotUsed, "Duration", 1, new("kind", "single")); + + ReadOnlySpan> tags = [new("first", 1), new("second", 2)]; + RoslynTelemetry.Count(FunctionId.TestEvent_NotUsed, "Count", 1, tags); + RoslynTelemetry.Record(FunctionId.TestEvent_NotUsed, "Duration", 1, tags); + + Assert.Equal([1, 2], sink.CounterTagCounts); + Assert.Equal([1, 2], sink.DistributionTagCounts); + } + /// /// The overloads that take an already-built own it, so they must return it /// to the pool even when nothing is listening - otherwise the pool is defeated on exactly the hosts @@ -140,10 +173,10 @@ static void AssertReturnedToPool(Action log) [InlineData(false)] public void BlockEndCarriesDeltaOnlyWhenLogDeltaIsSet(bool logDelta) { - var logger = new TestTelemetryLogger(logDelta); + var logger = new TestTelemetryEventSink(logDelta); using var _ = RoslynTelemetry.AddEventSink(logger); - TestTelemetryLogger.TestScope scope; + TestTelemetryEventSink.TestScope scope; // LogType.UserAction carries LogLevel.Information; anything lower is dropped by the sink. using (RoslynTelemetry.LogBlock( diff --git a/src/Workspaces/CoreTestUtilities/Logging/TestTelemetryLogger.cs b/src/Workspaces/CoreTestUtilities/Logging/TestTelemetryEventSink.cs similarity index 92% rename from src/Workspaces/CoreTestUtilities/Logging/TestTelemetryLogger.cs rename to src/Workspaces/CoreTestUtilities/Logging/TestTelemetryEventSink.cs index 9e7b293241adb..db2b7e74ecea4 100644 --- a/src/Workspaces/CoreTestUtilities/Logging/TestTelemetryLogger.cs +++ b/src/Workspaces/CoreTestUtilities/Logging/TestTelemetryEventSink.cs @@ -10,9 +10,9 @@ namespace Microsoft.CodeAnalysis.UnitTests.Logging; -internal sealed class TestTelemetryLogger : TelemetryLogger +internal sealed class TestTelemetryEventSink : TelemetryEventSink { - public TestTelemetryLogger(bool logDelta = false) + public TestTelemetryEventSink(bool logDelta = false) { LogDelta = logDelta; } diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs index 9255689629c6e..fc1c848686d06 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteProcessTelemetryService.cs @@ -83,8 +83,8 @@ public ValueTask EnableLoggingAsync(ImmutableArray loggerTypeNames, Immu var functionIdsSet = new HashSet(functionIds); bool logChecker(FunctionId id) => functionIdsSet.Contains(id); - Register(ref s_etwRegistration, loggerTypeNames.Contains(nameof(EtwLogger)), () => new EtwLogger(logChecker)); - Register(ref s_traceRegistration, loggerTypeNames.Contains(nameof(TraceLogger)), () => new TraceLogger(logChecker)); + Register(ref s_etwRegistration, loggerTypeNames.Contains(nameof(EtwEventSink)), () => new EtwEventSink(logChecker)); + Register(ref s_traceRegistration, loggerTypeNames.Contains(nameof(TraceEventSink)), () => new TraceEventSink(logChecker)); }, cancellationToken); // Its predicate is a snapshot of the per-FunctionId options, so a fresh sink is built on every diff --git a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs index 225e007fbdfb7..bf95c20df6182 100644 --- a/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs +++ b/src/Workspaces/Remote/ServiceHub/Services/ProcessTelemetry/RemoteWorkspaceTelemetryService.cs @@ -18,5 +18,5 @@ namespace Microsoft.VisualStudio.LanguageServices.Telemetry; internal sealed class RemoteWorkspaceTelemetryService() : AbstractWorkspaceTelemetryService { protected override ImmutableArray CreateEventSinks(TelemetrySession telemetrySession, bool logDelta) - => [TelemetryLogger.Create(telemetrySession, logDelta)]; + => [TelemetryEventSink.Create(telemetrySession, logDelta)]; } diff --git a/src/Workspaces/Remote/ServiceHubTest/TelemetryLoggerTests.cs b/src/Workspaces/Remote/ServiceHubTest/TelemetryEventSinkTests.cs similarity index 94% rename from src/Workspaces/Remote/ServiceHubTest/TelemetryLoggerTests.cs rename to src/Workspaces/Remote/ServiceHubTest/TelemetryEventSinkTests.cs index dd92413a633b2..b3d4a87c776b2 100644 --- a/src/Workspaces/Remote/ServiceHubTest/TelemetryLoggerTests.cs +++ b/src/Workspaces/Remote/ServiceHubTest/TelemetryEventSinkTests.cs @@ -13,7 +13,7 @@ namespace Microsoft.CodeAnalysis.UnitTests; -public sealed class TelemetryLoggerTests +public sealed class TelemetryEventSinkTests { private static IEnumerable InspectProperties(TelemetryEvent @event, string? keyToIgnoreValueInspection = null) => @event.Properties.Select(p => $"{p.Key}={(keyToIgnoreValueInspection == p.Key ? string.Empty : InspectPropertyValue(p.Value))}"); @@ -29,7 +29,7 @@ private static string InspectPropertyValue(object? value) [Theory, CombinatorialData] internal void IgnoredSeverity(LogLevel level) { - var logger = new TestTelemetryLogger(); + var logger = new TestTelemetryEventSink(); logger.Log(FunctionId.Debugging_EncSession_EditSession_EmitDeltaErrorId, LogMessage.Create("test", level)); Assert.Equal((level < LogLevel.Information) ? 0 : 1, logger.PostedEvents.Count); @@ -38,7 +38,7 @@ internal void IgnoredSeverity(LogLevel level) [Fact] public void EventWithProperties() { - var logger = new TestTelemetryLogger(); + var logger = new TestTelemetryEventSink(); logger.Log(FunctionId.Debugging_EncSession_EditSession_EmitDeltaErrorId, KeyValueLogMessage.Create(p => { @@ -62,7 +62,7 @@ public void EventWithProperties() [Theory, CombinatorialData] public void LogBlockStartEnd(bool logDelta) { - var logger = new TestTelemetryLogger(logDelta); + var logger = new TestTelemetryEventSink(logDelta); logger.LogBlockStart(FunctionId.Debugging_EncSession_EditSession_EmitDeltaErrorId, KeyValueLogMessage.Create(p => p.Add("test", "start"), logLevel: LogLevel.Information), blockId: 1, CancellationToken.None); diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems index 5da7d4b480508..124a0bd94dfbe 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/CompilerExtensions.projitems @@ -338,8 +338,8 @@ - - + + diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry_LogBlock.cs similarity index 96% rename from src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs rename to src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry_LogBlock.cs index c78ddcea4d509..79890ab57a334 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.LogBlock.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry_LogBlock.cs @@ -25,7 +25,7 @@ public static IDisposable CreateLogBlock(ImmutableArray sinks, Funct } /// - /// This tracks the logged message. On instantiation, it logs 'Started block' with other event data. + /// Tracks the logged message. On instantiation, it logs 'Started block' with other event data. /// On dispose, it logs 'Ended block' with the same event data so we can track which block started and ended when looking at logs. /// private sealed class RoslynLogBlock(ObjectPool pool) : IDisposable diff --git a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry_Metrics.cs similarity index 89% rename from src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs rename to src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry_Metrics.cs index f453c3053a081..93a075ffdfcc8 100644 --- a/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry.Metrics.cs +++ b/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/RoslynTelemetry_Metrics.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; +using System.Runtime.CompilerServices; using Roslyn.Utilities; namespace Microsoft.CodeAnalysis.Internal.Log; @@ -63,9 +64,13 @@ public static void Count(FunctionId functionId, string metricName, long delta, K } /// - /// Span-based entry point, shared by the fixed-arity overloads above. Not public: it is ambiguous - /// with the single-tag overload at call sites that use target-typed new(...). + /// Span-based entry point for callers with a dynamic number of tags. The lower overload resolution + /// priority allows target-typed new(...) to select the fixed-arity overloads above. /// + [OverloadResolutionPriority(-1)] + public static void Count(FunctionId functionId, string metricName, long delta, ReadOnlySpan> tags) + => CountCore(functionId, metricName, delta, tags); + private static void CountCore(FunctionId functionId, string metricName, long delta, ReadOnlySpan> tags) { var sinks = s_metricSinks; @@ -104,6 +109,10 @@ public static void Record(FunctionId functionId, string metricName, long value, RecordCore(functionId, metricName, value, tags); } + [OverloadResolutionPriority(-1)] + public static void Record(FunctionId functionId, string metricName, long value, ReadOnlySpan> tags) + => RecordCore(functionId, metricName, value, tags); + private static void RecordCore(FunctionId functionId, string metricName, long value, ReadOnlySpan> tags) { var sinks = s_metricSinks; From 0da13072e083b95423cd874aacf100c9661f1f20 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 2 Sep 2026 13:33:06 -0700 Subject: [PATCH 32/34] potential simplification for flush lock --- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index a998141fb6cb7..58d6dafa43bfe 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -62,16 +62,14 @@ private sealed class Aggregation(IInstrument instrument, TelemetryEvent telemetr public TelemetryEvent TelemetryEvent { get; } = telemetryEvent; /// - /// Guards this single aggregation. Held together with : - /// concurrent PostMetricEvent calls for one instrument crash the VS Telemetry SDK, so a - /// flush must exclude both other flushes and any in-flight Add/Record on the same instrument. + /// Guards this single aggregation while it is updated, posted, and retired. This prevents an + /// in-flight Add/Record from overlapping PostMetricEvent on the same instrument and ensures + /// that only one concurrent flush posts it. /// See https://github.com/dotnet/roslyn/pull/71606. /// public object Lock { get; } = new(); } - private readonly object _flushLock = new(); - private readonly VSTelemetryMeterProvider _meterProvider = new(); private readonly IMetricPoster _poster; private readonly CancellationTokenSource _flushLoopCancellation = new(); @@ -166,29 +164,29 @@ private void Update(string eventName, string metricName, ReadOnlySpan counter => new TelemetryCounterEvent(aggregation.TelemetryEvent, counter), - IHistogram histogram => new TelemetryHistogramEvent(aggregation.TelemetryEvent, histogram), - _ => throw ExceptionUtilities.UnexpectedValue(aggregation.Instrument), - }; - - _poster.Post(aggregation.TelemetryEvent, metricEvent); - - // Removed per key rather than clearing at the end, so measurements recorded under a - // new key while this loop runs survive to the next flush. - ImmutableInterlocked.TryRemove(ref _aggregations, pair.Key, out _); - } + ICounter counter => new TelemetryCounterEvent(aggregation.TelemetryEvent, counter), + IHistogram histogram => new TelemetryHistogramEvent(aggregation.TelemetryEvent, histogram), + _ => throw ExceptionUtilities.UnexpectedValue(aggregation.Instrument), + }; + + _poster.Post(aggregation.TelemetryEvent, metricEvent); + + // Removed per key rather than clearing at the end, so measurements recorded under a + // new key while this loop runs survive to the next flush. + ImmutableInterlocked.TryRemove(ref _aggregations, pair.Key, out _); } } } From f203195bd740a1aed8f73854bf47d54ecc740bf7 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 2 Sep 2026 14:03:09 -0700 Subject: [PATCH 33/34] simplify tests --- .../VSMetricSinkTests.cs | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs index ad4086c8333f2..4373d247871c6 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/VSMetricSinkTests.cs @@ -1,11 +1,10 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. using System; using System.Collections.Generic; using System.Threading; -using System.Threading.Tasks; using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Telemetry; using Microsoft.VisualStudio.Telemetry; @@ -154,40 +153,27 @@ public void CountersAndDistributionsDoNotShareABucket() [Fact] public void AMeasurementTakenDuringAFlushIsNotDropped() { - using var insideFlush = new ManualResetEventSlim(); - using var releaseFlush = new ManualResetEventSlim(); - var poster = new RecordingPoster(); using var sink = VSMetricSink.TestAccessor.CreateSink(poster); - // Post runs while the flush holds the aggregation lock, so it is the point where a concurrent - // Count is guaranteed to be blocked. + var counting = new Thread(() => sink.Count("vs/ide/vbcs/test/race", "Count", 1, default)); + var countWasBlocked = false; + + // Post runs while the flush holds the aggregation lock. Starting the count here ensures it has + // resolved the aggregation being posted once it blocks on that lock. poster.OnPost = () => { - insideFlush.Set(); - releaseFlush.Wait(); + counting.Start(); + countWasBlocked = SpinWait.SpinUntil( + () => (counting.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(10)); }; sink.Count("vs/ide/vbcs/test/race", "Count", 1, default); + sink.Flush(); - var flushing = Task.Run(sink.Flush); - insideFlush.Wait(); - - var counting = new Thread(() => sink.Count("vs/ide/vbcs/test/race", "Count", 1, default)); - counting.Start(); - - // The counting thread has resolved the aggregation being posted once it blocks on its lock. - var countWasBlocked = SpinWait.SpinUntil( - () => (counting.ThreadState & ThreadState.WaitSleepJoin) != 0, - TimeSpan.FromSeconds(10)); - - releaseFlush.Set(); - - flushing.Wait(); - var countCompleted = counting.Join(TimeSpan.FromSeconds(10)); - + Assert.True(counting.Join(TimeSpan.FromSeconds(10))); Assert.True(countWasBlocked); - Assert.True(countCompleted); Assert.Single(poster.Posted); // The second measurement must still be pending, not lost with the retired aggregation. From aca50e93210405d3501b8845dbbf67959fb492b3 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 2 Sep 2026 17:11:47 -0700 Subject: [PATCH 34/34] Address metric sink review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/instructions/IDE.instructions.md | 13 ------------- .github/memory/FILE_MAP.md | 4 ++-- .../Core/Def/Telemetry/Shared/VSMetricSink.cs | 4 +++- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/instructions/IDE.instructions.md b/.github/instructions/IDE.instructions.md index d5cd001bf5928..f6a1e030f6da1 100644 --- a/.github/instructions/IDE.instructions.md +++ b/.github/instructions/IDE.instructions.md @@ -101,16 +101,3 @@ var methodDecl = generator.MethodDeclaration("MyMethod", ...); - **ImportingConstructor must be marked `[Obsolete]`** with `MefConstruction.ImportingConstructorMessage` - **Language services must be exported with a specific language name** — don't use generic exports for both C#/VB - **Workspace changes must use immutable updates** — `Workspace.SetCurrentSolution()` - -## Telemetry - -- The shared entry point and sink contracts live under - `src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Log/`: `RoslynTelemetry`, - `IEventSink`, and `IMetricSink`. -- Name concrete `IEventSink` implementations with the `EventSink` suffix. Visual Studio implementations - live under `src/VisualStudio/Core/Def/Telemetry/`; `TelemetryEventSink` and `VSMetricSink` are source-linked - into the Language Server and ServiceHub hosts. -- Name `RoslynTelemetry` partial files with an underscore, such as - `RoslynTelemetry_Metrics.cs` and `RoslynTelemetry_LogBlock.cs`. -- Fixed-arity metric overloads avoid allocating tag arrays. Dynamic-tag span overloads use - `OverloadResolutionPriorityAttribute` so target-typed single-tag calls select the fixed-arity overload. diff --git a/.github/memory/FILE_MAP.md b/.github/memory/FILE_MAP.md index 47ba8fe0ed2cf..6dec7aa99ed1f 100644 --- a/.github/memory/FILE_MAP.md +++ b/.github/memory/FILE_MAP.md @@ -19,11 +19,11 @@ This file is a **top-level map only**. For per-area directory detail, read the m | `Dependencies/` | compiler | High-performance pooled collections & threading. | | `ExpressionEvaluator/` | compiler | Debugger expression evaluator. | | `Tools/` | compiler | Compiler and infrastructure tooling (including `dotnet-roslyn-tools`) and benchmark harnesses. | -| `Workspaces/` | ide | Solution/Project/Document model, MSBuild loading, Remote (OOP), and the shared `RoslynTelemetry` entry point/sink contracts. | +| `Workspaces/` | ide | Solution/Project/Document model, MSBuild loading, Remote (OOP). | | `Features/`, `EditorFeatures/` | ide | IDE feature logic and editor integration. | | `Analyzers/`, `CodeStyle/` | ide | IDE0xxx code-style analyzers & fixes. | | `LanguageServer/` | ide | LSP server. | -| `VisualStudio/` | ide | VS language services, UI, and Visual Studio telemetry sink implementations shared with other hosts. | +| `VisualStudio/` | ide | VS language services & UI. | | `Razor/src/` | razor | Razor compiler + tooling (own sub-tree layout). | | `Scripting/`, `Interactive/` | — | C#/VB scripting engine and REPL. | | `RoslynAnalyzers/` | — | Shipping `Microsoft.CodeAnalysis.*` analyzer packages. | diff --git a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs index 58d6dafa43bfe..833b243f77fa7 100644 --- a/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs +++ b/src/VisualStudio/Core/Def/Telemetry/Shared/VSMetricSink.cs @@ -186,7 +186,9 @@ public void Flush() // Removed per key rather than clearing at the end, so measurements recorded under a // new key while this loop runs survive to the next flush. - ImmutableInterlocked.TryRemove(ref _aggregations, pair.Key, out _); + Contract.ThrowIfFalse( + ImmutableInterlocked.TryRemove(ref _aggregations, pair.Key, out var removed) && + removed == aggregation); } } }