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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ Notes](../../RELEASENOTES.md).
* Reduce the overhead of GZip compression.
([#7275](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7275))

* Cached pre-serialized resource bytes to avoid re-encoding on every OTLP export.
([#7303](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7303))

## 1.15.3

Released 2026-Apr-21
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,62 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

using System.Buffers;
using System.Collections.Concurrent;
using OpenTelemetry.Resources;

namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Serializer;

internal static class ProtobufOtlpResourceSerializer
{
private const int ReserveSizeForLength = 4;
private const int InitialBufferSize = 2048;

private static readonly ConcurrentDictionary<Resource, byte[]> CachedResourceBytes = new();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

unsure if static is the right choice here. if we store it in MeterProvider or exporter itself, then we can avoid the Dictionary completely as there is single Resource associated with an exporter always.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So you mean move the cache up one level to the caller?

That sounds similar to something I've done in #7279 to cache Prometheus metrics.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@cijothomas , Resource is immutable, isn't it? static dictionary is the right choice here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Immutability of Resource isn't really the concern — the issue is lifetime. A static ConcurrentDictionary<Resource, byte[]> strongly references every Resource instance the process ever sees, including ones from disposed MeterProviders. For Resource it's usually bounded in practice so the leak is small, but caching on the exporter/provider (where there's a single Resource per exporter) would avoid the dictionary entirely and bound the lifetime correctly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I thought about it, but the changes surface was quite big, plus same bytes would be effectively duplicated across 3 exporters. While static dictionary is a simple change and I thought that Resource reference is typically living for a lifetime of the application. Several dangling references for a long-running processes, is usually acceptable. Still it's quite easy to swap ConcurrentDicitonary with ConditionalWeakTable here with loosing a bit on perf if dangling references are problem.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fair. This one has low risk, but it'd be still better to switch to ConditionalWeakTable here in a follow up PR. I am more worried about the Metric/scope change PR (#7307), as that has more real leak risks.


private static ReadOnlySpan<byte> EmptyResourceBytes => [0x0A, 0x80, 0x80, 0x80, 0x00];

internal static int WriteResource(byte[] buffer, int writePosition, Resource? resource)
{
if (resource == null || resource == Resource.Empty)
{
EmptyResourceBytes.CopyTo(buffer.AsSpan(writePosition));
return writePosition + EmptyResourceBytes.Length;
}

var cached = CachedResourceBytes.GetOrAdd(resource, static r => SerializeResourceToBytes(r));
Buffer.BlockCopy(cached, 0, buffer, writePosition, cached.Length);
return writePosition + cached.Length;
}

private static byte[] SerializeResourceToBytes(Resource resource)
{
var pool = ArrayPool<byte>.Shared;

var buffer = pool.Rent(InitialBufferSize);
try
{
while (true)
{
try
{
var length = WriteResourceCore(buffer, 0, resource);
return buffer.AsSpan(0, length).ToArray();
}
catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException)
{
pool.Return(buffer);
buffer = pool.Rent(buffer.Length * 2);
}
}
}
finally
{
pool.Return(buffer);
}
}

private static int WriteResourceCore(byte[] buffer, int writePosition, Resource resource)
{
var otlpTagWriterState = new ProtobufOtlpTagWriter.OtlpTagWriterState
{
Expand All @@ -21,21 +68,18 @@ internal static int WriteResource(byte[] buffer, int writePosition, Resource? re
var resourceLengthPosition = otlpTagWriterState.WritePosition;
otlpTagWriterState.WritePosition += ReserveSizeForLength;

if (resource != null && resource != Resource.Empty)
if (resource.Attributes is IReadOnlyList<KeyValuePair<string, object>> resourceAttributesList)
{
if (resource.Attributes is IReadOnlyList<KeyValuePair<string, object>> resourceAttributesList)
for (var i = 0; i < resourceAttributesList.Count; i++)
{
for (var i = 0; i < resourceAttributesList.Count; i++)
{
ProcessResourceAttribute(ref otlpTagWriterState, resourceAttributesList[i]);
}
ProcessResourceAttribute(ref otlpTagWriterState, resourceAttributesList[i]);
}
else
}
else
{
foreach (var attribute in resource.Attributes)
{
foreach (var attribute in resource.Attributes)
{
ProcessResourceAttribute(ref otlpTagWriterState, attribute);
}
ProcessResourceAttribute(ref otlpTagWriterState, attribute);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

extern alias OpenTelemetryProtocol;

using BenchmarkDotNet.Attributes;
using OpenTelemetry.Resources;
using OpenTelemetryProtocol::OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Serializer;

namespace Benchmarks.Exporter;

public class ProtobufOtlpResourceSerializerBenchmarks
{
private static readonly KeyValuePair<string, object>[] AllAttributes =
[
new("service.name", "checkout-api"),
new("service.namespace", "shop"),
new("service.version", "2.4.1"),
new("service.instance.id", "9a8d1f3e-4b2c-4e15-9a4f-1b2c3d4e5f6a"),
new("telemetry.sdk.name", "opentelemetry"),
new("telemetry.sdk.language", "dotnet"),
new("telemetry.sdk.version", "1.13.0"),
new("deployment.environment", "production"),
new("host.name", "ip-10-0-12-47.eu-west-1.compute.internal"),
new("host.id", "i-0abcdef1234567890"),
new("host.type", "c6a.2xlarge"),
new("host.arch", "amd64"),
new("os.type", "linux"),
new("os.description", "Ubuntu 22.04.4 LTS"),
new("process.pid", 18742L),
new("process.executable.name", "Checkout.Api"),
new("process.runtime.name", ".NET"),
new("process.runtime.version", "10.0.8"),
new("container.id", "8f3a7b4c9e1d2f5a6b8c0e2f4a6b8d0c2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b"),
new("container.image.name", "registry.example.com/shop/checkout-api"),
new("container.image.tag", "2.4.1-abc1234"),
new("k8s.namespace.name", "shop-prod"),
new("k8s.pod.name", "checkout-api-7d8c9f4b6c-x9k2m"),
new("k8s.pod.uid", "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a"),
new("k8s.node.name", "ip-10-0-12-47.eu-west-1.compute.internal"),
new("k8s.deployment.name", "checkout-api"),
new("k8s.cluster.name", "shop-prod-eu-west-1"),
new("cloud.provider", "aws"),
new("cloud.region", "eu-west-1"),
new("cloud.availability_zone", "eu-west-1a"),
new("cloud.account.id", "123456789012"),
new("deployment.id", "deploy-2024-05-17-abc"),
];

private readonly byte[] buffer = new byte[64 * 1024];
private Resource resource = Resource.Empty;

[Params(0, 4, 8, 16, 32)]
public int AttributeCount { get; set; }

[GlobalSetup]
public void Setup()
{
if (this.AttributeCount == 0)
{
this.resource = Resource.Empty;
return;
}

var attributes = new Dictionary<string, object>(this.AttributeCount);
var count = Math.Min(this.AttributeCount, AllAttributes.Length);
for (var i = 0; i < count; i++)
{
attributes[AllAttributes[i].Key] = AllAttributes[i].Value;
}

for (var i = count; i < this.AttributeCount; i++)
{
attributes[$"custom.attribute.{i}"] = Guid.NewGuid().ToString();
}

this.resource = new Resource(attributes);
}

[Benchmark]
public int WriteResource() => ProtobufOtlpResourceSerializer.WriteResource(this.buffer, 0, this.resource);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests;

public class OtlpResourceTests
{
[Fact]
public void EmptyResourceSerializesToExpectedBytes()
{
var buffer = new byte[5];
var writePosition = ProtobufOtlpResourceSerializer.WriteResource(buffer, 0, Resource.Empty);

Assert.Equal(5, writePosition);
Assert.Equal(new byte[] { 0x0A, 0x80, 0x80, 0x80, 0x00 }, buffer);
}

[Fact]
public void NullResourceSerializesToExpectedBytes()
{
var buffer = new byte[5];
var writePosition = ProtobufOtlpResourceSerializer.WriteResource(buffer, 0, resource: null);

Assert.Equal(5, writePosition);
Assert.Equal(new byte[] { 0x0A, 0x80, 0x80, 0x80, 0x00 }, buffer);
}

[Theory]
[InlineData(true)]
[InlineData(false)]
Expand Down
Loading