diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md index e2a877c02ce..6f2a230afb3 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md @@ -18,6 +18,19 @@ option * Fix handling of array-valued attributes for the OTLP trace exporter. ([#3238](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3238)) +* Improve the conversion and formatting of attribute values to the OTLP format + for resources, metrics, and logs. The list of data types that must be + supported per the + [OpenTelemetry specification](https://github.com/open-telemetry/opentelemetry-specification/tree/main/specification/common#attribute) + is more narrow than what the .NET OpenTelemetry SDK supports. Numeric + [built-in value types](https://docs.microsoft.com/dotnet/csharp/language-reference/builtin-types/built-in-types) + are supported by converting to a `long` or `double` as appropriate except for + numeric types that could cause overflow (`ulong`) or rounding (`decimal`) + which are converted to strings. Non-numeric built-in types - `string`, + `char`, `bool` are supported. All other types are converted to a `string`. + Array values are also supported. + ([#3262](https://github.com/open-telemetry/opentelemetry-dotnet/pull/3262)) + ## 1.3.0-beta.1 Released 2022-Apr-15 diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ActivityExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ActivityExtensions.cs index fbc123db6b7..e6fe5027e7b 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ActivityExtensions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ActivityExtensions.cs @@ -201,41 +201,6 @@ internal static OtlpTrace.Span ToOtlpSpan(this Activity activity) return otlpSpan; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static OtlpCommon.KeyValue ToOtlpAttribute(this KeyValuePair kvp) - { - if (kvp.Value == null) - { - return null; - } - - var attrib = new OtlpCommon.KeyValue { Key = kvp.Key, Value = new OtlpCommon.AnyValue { } }; - - switch (kvp.Value) - { - case string s: - attrib.Value.StringValue = s; - break; - case bool b: - attrib.Value.BoolValue = b; - break; - case int i: - attrib.Value.IntValue = i; - break; - case long l: - attrib.Value.IntValue = l; - break; - case double d: - attrib.Value.DoubleValue = d; - break; - default: - attrib.Value.StringValue = kvp.Value.ToString(); - break; - } - - return attrib; - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static OtlpTrace.Status ToOtlpStatus(this Activity activity, ref TagEnumerationState otlpTags) { diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs index 17db6324a1b..9e3c905d748 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs @@ -79,5 +79,11 @@ public void CouldNotTranslateLogRecord(string exceptionMessage) { this.WriteEvent(9, exceptionMessage); } + + [Event(10, Message = "Unsupported attribute type '{0}' for '{1}'. Attribute will not be exported.", Level = EventLevel.Warning)] + public void UnsupportedAttributeType(string type, string key) + { + this.WriteEvent(10, type.ToString(), key); + } } } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpCommonExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpCommonExtensions.cs new file mode 100644 index 00000000000..4185969a483 --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpCommonExtensions.cs @@ -0,0 +1,130 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using OtlpCommon = Opentelemetry.Proto.Common.V1; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation +{ + internal static class OtlpCommonExtensions + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static OtlpCommon.KeyValue ToOtlpAttribute(this KeyValuePair kvp) + { + if (kvp.Value == null) + { + return null; + } + + var value = ToOtlpValue(kvp.Value); + + if (value == null) + { + OpenTelemetryProtocolExporterEventSource.Log.UnsupportedAttributeType(kvp.Value.GetType().ToString(), kvp.Key); + return null; + } + + return new OtlpCommon.KeyValue { Key = kvp.Key, Value = value }; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static OtlpCommon.AnyValue ToOtlpValue(object value) + { + switch (value) + { + case char: + case string: + return new OtlpCommon.AnyValue { StringValue = Convert.ToString(value) }; + case bool b: + return new OtlpCommon.AnyValue { BoolValue = b }; + case byte: + case sbyte: + case short: + case ushort: + case int: + case uint: + case long: + return new OtlpCommon.AnyValue { IntValue = Convert.ToInt64(value) }; + case float: + case double: + return new OtlpCommon.AnyValue { DoubleValue = Convert.ToDouble(value) }; + case Array array: + return ToOtlpArrayValue(array); + + // All other types are converted to strings including the following + // built-in value types: + // case nint: Pointer type. + // case nuint: Pointer type. + // case ulong: May throw an exception on overflow. + // case decimal: Converting to double produces rounding errors. + default: + try + { + return new OtlpCommon.AnyValue { StringValue = value.ToString() }; + } + catch + { + return null; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static OtlpCommon.AnyValue ToOtlpArrayValue(Array array) + { +#pragma warning disable SA1011 // Closing square brackets should be spaced correctly + var arrayValue = new OtlpCommon.ArrayValue(); + switch (array) + { + case char[]: + case string[]: + case bool[]: + case byte[]: + case sbyte[]: + case short[]: + case ushort[]: + case int[]: + case uint[]: + case long[]: + case float[]: + case double[]: + foreach (var item in array) + { + arrayValue.Values.Add(ToOtlpValue(item)); + } + + return new OtlpCommon.AnyValue { ArrayValue = arrayValue }; + default: + foreach (var item in array) + { + try + { + arrayValue.Values.Add(ToOtlpValue(item.ToString())); + } + catch + { + return null; + } + } + + return new OtlpCommon.AnyValue { ArrayValue = arrayValue }; + } +#pragma warning restore SA1011 // Closing square brackets should be spaced correctly + } + } +} diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpAttributeTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpAttributeTests.cs new file mode 100644 index 00000000000..dac7054690d --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpAttributeTests.cs @@ -0,0 +1,245 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; +using Xunit; +using OtlpCommon = Opentelemetry.Proto.Common.V1; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests +{ + public class OtlpAttributeTests + { + [Fact] + public void NullValueAttribute() + { + var kvp = new KeyValuePair("key", null); + var attribute = kvp.ToOtlpAttribute(); + Assert.Null(attribute); + } + + [Fact] + public void EmptyArrays() + { + var kvp = new KeyValuePair("key", new int[] { }); + var attribute = kvp.ToOtlpAttribute(); + Assert.NotNull(attribute); + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.ArrayValue, attribute.Value.ValueCase); + Assert.Empty(attribute.Value.ArrayValue.Values); + + kvp = new KeyValuePair("key", new object[] { }); + attribute = kvp.ToOtlpAttribute(); + Assert.NotNull(attribute); + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.ArrayValue, attribute.Value.ValueCase); + Assert.Empty(attribute.Value.ArrayValue.Values); + } + + [Theory] + [InlineData(sbyte.MaxValue)] + [InlineData(byte.MaxValue)] + [InlineData(short.MaxValue)] + [InlineData(ushort.MaxValue)] + [InlineData(int.MaxValue)] + [InlineData(uint.MaxValue)] + [InlineData(long.MaxValue)] + [InlineData(new sbyte[] { 1, 2, 3 })] + [InlineData(new byte[] { 1, 2, 3 })] + [InlineData(new short[] { 1, 2, 3 })] + [InlineData(new ushort[] { 1, 2, 3 })] + [InlineData(new int[] { 1, 2, 3 })] + [InlineData(new uint[] { 1, 2, 3 })] + [InlineData(new long[] { 1, 2, 3 })] + public void IntegralTypesSupported(object value) + { + var kvp = new KeyValuePair("key", value); + var attribute = kvp.ToOtlpAttribute(); + Assert.NotNull(attribute); + + switch (value) + { + case Array array: + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.ArrayValue, attribute.Value.ValueCase); + var expectedArray = new long[array.Length]; + for (var i = 0; i < array.Length; i++) + { + expectedArray[i] = Convert.ToInt64(array.GetValue(i)); + } + + Assert.Equal(expectedArray, attribute.Value.ArrayValue.Values.Select(x => x.IntValue)); + break; + default: + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.IntValue, attribute.Value.ValueCase); + Assert.Equal(Convert.ToInt64(value), attribute.Value.IntValue); + break; + } + } + + [Theory] + [InlineData(float.MaxValue)] + [InlineData(double.MaxValue)] + [InlineData(new float[] { 1, 2, 3 })] + [InlineData(new double[] { 1, 2, 3 })] + public void FloatingPointTypesSupported(object value) + { + var kvp = new KeyValuePair("key", value); + var attribute = kvp.ToOtlpAttribute(); + Assert.NotNull(attribute); + + switch (value) + { + case Array array: + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.ArrayValue, attribute.Value.ValueCase); + var expectedArray = new double[array.Length]; + for (var i = 0; i < array.Length; i++) + { + expectedArray[i] = Convert.ToDouble(array.GetValue(i)); + } + + Assert.Equal(expectedArray, attribute.Value.ArrayValue.Values.Select(x => x.DoubleValue)); + break; + default: + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.DoubleValue, attribute.Value.ValueCase); + Assert.Equal(Convert.ToDouble(value), attribute.Value.DoubleValue); + break; + } + } + + [Theory] + [InlineData(true)] + [InlineData(new bool[] { true, false, true })] + public void BooleanTypeSupported(object value) + { + var kvp = new KeyValuePair("key", value); + var attribute = kvp.ToOtlpAttribute(); + Assert.NotNull(attribute); + + switch (value) + { + case Array array: + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.ArrayValue, attribute.Value.ValueCase); + var expectedArray = new bool[array.Length]; + for (var i = 0; i < array.Length; i++) + { + expectedArray[i] = Convert.ToBoolean(array.GetValue(i)); + } + + Assert.Equal(expectedArray, attribute.Value.ArrayValue.Values.Select(x => x.BoolValue)); + break; + default: + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.BoolValue, attribute.Value.ValueCase); + Assert.Equal(Convert.ToBoolean(value), attribute.Value.BoolValue); + break; + } + } + + [Theory] + [InlineData(char.MaxValue)] + [InlineData("string")] + public void StringTypesSupported(object value) + { + var kvp = new KeyValuePair("key", value); + var attribute = kvp.ToOtlpAttribute(); + Assert.NotNull(attribute); + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.StringValue, attribute.Value.ValueCase); + Assert.Equal(Convert.ToString(value), attribute.Value.StringValue); + } + + [Fact] + public void StringArrayTypesSupported() + { + var charArray = new char[] { 'a', 'b', 'c' }; + var stringArray = new string[] { "a", "b", "c" }; + + var kvp = new KeyValuePair("key", charArray); + var attribute = kvp.ToOtlpAttribute(); + Assert.NotNull(attribute); + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.ArrayValue, attribute.Value.ValueCase); + Assert.Equal(stringArray, attribute.Value.ArrayValue.Values.Select(x => x.StringValue)); + + kvp = new KeyValuePair("key", stringArray); + attribute = kvp.ToOtlpAttribute(); + Assert.NotNull(attribute); + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.ArrayValue, attribute.Value.ValueCase); + Assert.Equal(stringArray, attribute.Value.ArrayValue.Values.Select(x => x.StringValue)); + } + + [Fact] + public void ToStringIsCalledForAllOtherTypes() + { + var testValues = new object[] + { + (nint)int.MaxValue, + (nuint)uint.MaxValue, + decimal.MaxValue, + new object(), + }; + + var testArrayValues = new object[] + { + new nint[] { 1, 2, 3 }, + new nuint[] { 1, 2, 3 }, + new decimal[] { 1, 2, 3 }, + new object[] { 1, new object(), false }, + }; + + foreach (var value in testValues) + { + var kvp = new KeyValuePair("key", value); + var attribute = kvp.ToOtlpAttribute(); + Assert.NotNull(attribute); + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.StringValue, attribute.Value.ValueCase); + Assert.Equal(value.ToString(), attribute.Value.StringValue); + } + + foreach (var value in testArrayValues) + { + var kvp = new KeyValuePair("key", value); + var attribute = kvp.ToOtlpAttribute(); + Assert.NotNull(attribute); + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.ArrayValue, attribute.Value.ValueCase); + + var array = value as Array; + for (var i = 0; i < attribute.Value.ArrayValue.Values.Count; ++i) + { + Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.StringValue, attribute.Value.ArrayValue.Values[i].ValueCase); + Assert.Equal(array.GetValue(i).ToString(), attribute.Value.ArrayValue.Values[i].StringValue); + } + } + } + + [Fact] + public void ExceptionInToStringIsCaught() + { + var kvp = new KeyValuePair("key", new MyToStringMethodThrowsAnException()); + var attribute = kvp.ToOtlpAttribute(); + Assert.Null(attribute); + + kvp = new KeyValuePair("key", new object[] { 1, false, new MyToStringMethodThrowsAnException() }); + attribute = kvp.ToOtlpAttribute(); + Assert.Null(attribute); + } + + private class MyToStringMethodThrowsAnException + { + public override string ToString() + { + throw new Exception("Nope."); + } + } + } +}