Skip to content

Commit

Permalink
Advanced .NET metrics support (#137)
Browse files Browse the repository at this point in the history
  • Loading branch information
cretz committed Oct 6, 2023
1 parent 5cf7a4a commit 516c72b
Show file tree
Hide file tree
Showing 50 changed files with 2,222 additions and 531 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ jobs:
path: |
src/Temporalio/bin/Release/*.nupkg
src/Temporalio/bin/Release/*.snupkg
src/Temporalio.Extensions.DiagnosticSource/bin/Release/*.nupkg
src/Temporalio.Extensions.DiagnosticSource/bin/Release/*.snupkg
src/Temporalio.Extensions.Hosting/bin/Release/*.nupkg
src/Temporalio.Extensions.Hosting/bin/Release/*.snupkg
src/Temporalio.Extensions.OpenTelemetry/bin/Release/*.nupkg
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Also see:

Extensions:

* [Temporalio.Extensions.DiagnosticSource](https://github.com/temporalio/sdk-dotnet/tree/main/src/Temporalio.Extensions.DiagnosticSource) -
`System.Diagnostics.Metrics` support for SDK metrics
* [Temporalio.Extensions.Hosting](https://github.com/temporalio/sdk-dotnet/tree/main/src/Temporalio.Extensions.Hosting) -
Client dependency injection, activity dependency injection, and worker generic host support
* [Temporalio.Extensions.OpenTelemetry](https://github.com/temporalio/sdk-dotnet/tree/main/src/Temporalio.Extensions.OpenTelemetry) -
Expand Down
7 changes: 7 additions & 0 deletions Temporalio.sln
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.OpenT
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.Hosting", "src\Temporalio.Extensions.Hosting\Temporalio.Extensions.Hosting.csproj", "{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.DiagnosticSource", "src\Temporalio.Extensions.DiagnosticSource\Temporalio.Extensions.DiagnosticSource.csproj", "{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -40,11 +42,16 @@ Global
{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87}.Release|Any CPU.Build.0 = Release|Any CPU
{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7AE1422A-0937-40D7-9A62-431DD0E2F6D5} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{D5F245E2-73A2-49C6-8C52-FBE892E87169} = {F2683DAA-F157-448E-96C8-DF7BB019886D}
{D4AC2E2B-1C24-491D-9175-874D448D30FE} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
EndGlobalSection
EndGlobal
189 changes: 189 additions & 0 deletions src/Temporalio.Extensions.DiagnosticSource/CustomMetricMeter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Threading;
using Temporalio.Runtime;

namespace Temporalio.Extensions.DiagnosticSource
{
/// <summary>
/// Implementation of <see cref="ICustomMetricMeter" /> for a <see cref="Meter" /> that can be
/// set on <see cref="MetricsOptions.CustomMetricMeter" /> to record metrics to the meter.
/// </summary>
public class CustomMetricMeter : ICustomMetricMeter
{
/// <summary>
/// Initializes a new instance of the <see cref="CustomMetricMeter" /> class.
/// </summary>
/// <param name="meter">Meter to back this custom meter implementation with.</param>
public CustomMetricMeter(Meter meter) => Meter = meter;

/// <summary>
/// Gets the underlying meter for this custom meter.
/// </summary>
public Meter Meter { get; private init; }

/// <inheritdoc />
public ICustomMetricCounter<T> CreateCounter<T>(
string name, string? unit, string? description)
where T : struct =>
new CustomMetricCounter<T>(Meter.CreateCounter<T>(name, unit, description));

/// <inheritdoc />
public ICustomMetricHistogram<T> CreateHistogram<T>(
string name, string? unit, string? description)
where T : struct =>
new CustomMetricHistogram<T>(Meter.CreateHistogram<T>(name, unit, description));

/// <inheritdoc />
public ICustomMetricGauge<T> CreateGauge<T>(
string name, string? unit, string? description)
where T : struct
{
var gauge = new CustomMetricGauge<T>(name);
Meter.CreateObservableGauge(name, gauge.Snapshot, unit, description);
return gauge;
}

/// <inheritdoc />
public object CreateTags(
object? appendFrom, IReadOnlyCollection<KeyValuePair<string, object>> tags) =>
new Tags(tags, appendFrom);

private sealed class CustomMetricCounter<T> : ICustomMetricCounter<T>
where T : struct
{
private readonly Counter<T> underlying;

internal CustomMetricCounter(Counter<T> underlying) => this.underlying = underlying;

public void Add(T value, object tags) =>
underlying.Add(value, ((Tags)tags).TagList);
}

private sealed class CustomMetricHistogram<T> : ICustomMetricHistogram<T>
where T : struct
{
private readonly Histogram<T> underlying;

internal CustomMetricHistogram(Histogram<T> underlying) => this.underlying = underlying;

public void Record(T value, object tags) =>
underlying.Record(value, ((Tags)tags).TagList);
}

#pragma warning disable CA1001 // We are disposing the lock on destruction since this can't be disposable
private sealed class CustomMetricGauge<T> : ICustomMetricGauge<T>
#pragma warning restore CA1001
where T : struct
{
private readonly string name;
// We must dedupe based on tag set. We considered several designs including regular
// dictionary, blocking collection or concurrent queue w/ read time dedupe, and others
// and it was clearest to use a concurrent dictionary for collection but lock it while
// collecting metrics. Iterating a concurrent dictionary does not guarantee duplicates
// will be prevented, so we must lock it while iterating during collection.
private readonly ConcurrentDictionary<Tags, Measurement<T>> pending = new();
private readonly ReaderWriterLockSlim pendingLock = new();

internal CustomMetricGauge(string name) => this.name = name;

~CustomMetricGauge() => pendingLock.Dispose();

public void Set(T value, object tags)
{
// We need to support an extreme max here just to prevent unbounded memory growth.
// We do not want to require a logger for this, so at some extreme value we will
// just write to stderr and drop.
if (pending.Count > 50000)
{
Console.Error.WriteLine($"Dropping gauge metric {name} since cardinality has grown over 50k");
return;
}
// We are grabbing read lock because this is ok to happen concurrently even though
// technically we are doing more than reading
var tagsObj = (Tags)tags;
var measurement = new Measurement<T>(value, tagsObj.TagList);
pendingLock.EnterReadLock();
try
{
pending[tagsObj] = measurement;
}
finally
{
pendingLock.ExitReadLock();
}
}

// Used as callback for ObservableGauge
public List<Measurement<T>> Snapshot()
{
pendingLock.EnterWriteLock();
try
{
return pending.Values.ToList();
}
finally
{
pendingLock.ExitWriteLock();
}
}
}

private sealed class Tags : IEquatable<Tags>
{
private int? hashCode;

public Tags(
IReadOnlyCollection<KeyValuePair<string, object>> newTags, object? appendFrom)
{
// Build sorted tag array
IEnumerable<KeyValuePair<string, object>> tags;
if (appendFrom is Tags appendFromTags)
{
tags = appendFromTags.TagList.Concat(newTags);
}
else
{
tags = newTags;
}
TagList = new(new(tags.ToDictionary(
kv => kv.Key, kv => (object?)kv.Value).OrderBy(kv => kv.Key).ToArray()));
}

public TagList TagList { get; private init; }

public override bool Equals(object? obj) =>
obj is Tags tagsObj && Equals(tagsObj);

public bool Equals(Tags other)
{
if (other.TagList.Count != TagList.Count)
{
return false;
}
for (int i = 0; i < TagList.Count; i++)
{
var kv = TagList[i];
var otherKV = other.TagList[i];
if (kv.Key != otherKV.Key || !kv.Value!.Equals(otherKV.Value))
{
return false;
}
}
return true;
}

public override int GetHashCode()
{
// Values are already sorted, so we'll just xor the tuples' hash codes. We don't
// care about the race of this running multiple times at once.
hashCode ??= TagList.Aggregate(0, (acc, kv) => acc ^ (kv.Key, kv.Value).GetHashCode());
return hashCode.Value;
}
}
}
}
53 changes: 53 additions & 0 deletions src/Temporalio.Extensions.DiagnosticSource/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# System.Diagnostics.Metrics Support

This extension adds an implementation of `Temporalio.Runtime.ICustomMetricMeter` for `System.Diagnostics.Metrics.Meter`
so internal SDK metrics can be forwarded as regular .NET metrics.

⚠️ UNDER ACTIVE DEVELOPMENT

This SDK is under active development and has not released a stable version yet. APIs may change in incompatible ways
until the SDK is marked stable.

## Quick Start

Add the `Temporalio.Extensions.DiagnosticSource` package from
[NuGet](https://www.nuget.org/packages/Temporalio.Extensions.DiagnosticSource). For example, using the `dotnet` CLI:

dotnet add package Temporalio.Extensions.DiagnosticSource --prerelease

Now a `Temporalio.Runtime.TemporalRuntime` can be created with a .NET meter and a client can be created from that:

```csharp
using System.Diagnostics.Metrics;
using Temporalio.Client;
using Temporalio.Extensions.DiagnosticSource;
using Temporalio.Runtime;

// Create .NET meter
using var meter = new Meter("My.Meter");
// Can create MeterListener or OTel meter provider here...
// Create Temporal runtime with a custom metric meter for that meter
var runtime = new TemporalRuntime(new()
{
Telemetry = new()
{
Metrics = new() { CustomMetricMeter = new CustomMetricMeter(meter) },
},
});

// Create a client using that runtime
var client = await TemporalClient.ConnectAsync(
new()
{
TargetHost = "my-temporal-host:7233",
Namespace = "my-temporal-namespace",
Runtime = runtime,
});

// Now all metrics for the client will go through the .NET meter
```

This client can be used for the worker too which means that all metrics, including ones created during activity using
`ActivityExecutionContext.Current.MetricMeter` and during workflow using `Workflow.MetricMeter`, will be recorded on
the .NET meter.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>Temporal SDK .NET Diagnostic Source Extension</Description>
<EnablePackageValidation Condition="'$(TargetFramework)' == 'netcoreapp3.1'">true</EnablePackageValidation>
<IncludeSymbols>true</IncludeSymbols>
<LangVersion>9.0</LangVersion>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<TargetFrameworks>netstandard2.0;net462</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="7.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Temporalio\Temporalio.csproj" />
</ItemGroup>

<!-- Pack the README -->
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>
11 changes: 11 additions & 0 deletions src/Temporalio.Extensions.DiagnosticSource/_LanguageHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#pragma warning disable SA1649

namespace System.Runtime.CompilerServices
{
/// <summary>
/// Needed for init-only properties to work on older .NET versions.
/// </summary>
internal static class IsExternalInit
{
}
}
6 changes: 3 additions & 3 deletions src/Temporalio/Activities/ActivityExecutionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Temporalio.Activities
/// </summary>
public class ActivityExecutionContext
{
private readonly Lazy<IMetricMeter> metricMeter;
private readonly Lazy<MetricMeter> metricMeter;

/// <summary>
/// Initializes a new instance of the <see cref="ActivityExecutionContext"/> class.
Expand All @@ -35,7 +35,7 @@ internal ActivityExecutionContext(
ByteString taskToken,
ILogger logger,
IPayloadConverter payloadConverter,
Lazy<IMetricMeter> runtimeMetricMeter)
Lazy<MetricMeter> runtimeMetricMeter)
{
Info = info;
CancellationToken = cancellationToken;
Expand Down Expand Up @@ -105,7 +105,7 @@ internal ActivityExecutionContext(
/// Gets the metric meter for this activity with activity-specific tags. Note, this is
/// lazily created for each activity execution.
/// </summary>
public IMetricMeter MetricMeter => metricMeter.Value;
public MetricMeter MetricMeter => metricMeter.Value;

/// <summary>
/// Gets the async local current value.
Expand Down
Loading

0 comments on commit 516c72b

Please sign in to comment.