diff --git a/src/OpenTelemetry.Exporter.Instana/CHANGELOG.md b/src/OpenTelemetry.Exporter.Instana/CHANGELOG.md index 9d1048a38b..edbbf3259b 100644 --- a/src/OpenTelemetry.Exporter.Instana/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Instana/CHANGELOG.md @@ -15,6 +15,9 @@ `HttpClient` for the exporter to use. ([#4153](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/4153)) +* Use System.Text.Json for JSON serialization. + ([#4293](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/4293)) + ## 1.0.7 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Instana/Implementation/InstanaSpanSerializer.cs b/src/OpenTelemetry.Exporter.Instana/Implementation/InstanaSpanSerializer.cs index c7d5a2494e..41e25606eb 100644 --- a/src/OpenTelemetry.Exporter.Instana/Implementation/InstanaSpanSerializer.cs +++ b/src/OpenTelemetry.Exporter.Instana/Implementation/InstanaSpanSerializer.cs @@ -1,24 +1,13 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 -using System.Collections; using System.Globalization; +using System.Text.Json; namespace OpenTelemetry.Exporter.Instana.Implementation; -// TODO Use a proper JSON serializer that encodes strings safely. - internal static class InstanaSpanSerializer { - private const string Comma = ","; - private const string OpenCurlyBrace = "{"; - private const string CloseCurlyBrace = "}"; - private const string Quote = "\""; - private const string Colon = ":"; - private const string QuoteColon = Quote + Colon; - private const string QuoteColonQuote = Quote + Colon + Quote; - private const string QuoteCommaQuote = Quote + Comma + Quote; - private static readonly long UnixZeroTime = #if NET DateTimeOffset.UnixEpoch.Ticks; @@ -26,164 +15,112 @@ internal static class InstanaSpanSerializer new DateTime(1970, 1, 1, 0, 0, 0, 0).Ticks; #endif - internal static IEnumerator? GetSpanTagsEnumerator(InstanaSpan instanaSpan) => instanaSpan.Data.Tags.GetEnumerator(); - - internal static IEnumerator? GetSpanEventsEnumerator(InstanaSpan instanaSpan) => instanaSpan.Data.Events.GetEnumerator(); - - internal static void SerializeToStreamWriter(InstanaSpan instanaSpan, StreamWriter writer) + internal static void Serialize(InstanaSpan instanaSpan, Utf8JsonWriter writer) { - writer.Write(OpenCurlyBrace); + writer.WriteStartObject(); + AppendProperty(instanaSpan.T, "t", writer); - writer.Write(Comma); AppendProperty(instanaSpan.S, "s", writer); - writer.Write(Comma); if (!string.IsNullOrEmpty(instanaSpan.P)) { AppendProperty(instanaSpan.P, "p", writer); - writer.Write(Comma); } if (!string.IsNullOrEmpty(instanaSpan.Lt)) { AppendProperty(instanaSpan.Lt, "lt", writer); - writer.Write(Comma); } if (instanaSpan.Tp) { AppendProperty("true", "tp", writer); - writer.Write(Comma); } if (instanaSpan.K != SpanKind.NOT_SET) { AppendProperty((((int)instanaSpan.K) + 1).ToString(CultureInfo.InvariantCulture), "k", writer); - writer.Write(Comma); } AppendProperty(instanaSpan.N, "n", writer); - writer.Write(Comma); - AppendProperty(DateToUnixMillis(instanaSpan.Ts), "ts", writer); - writer.Write(Comma); - AppendProperty(instanaSpan.D / 10_000L, "d", writer); - writer.Write(Comma); - AppendObject(SerializeData, "data", instanaSpan, writer); - writer.Write(Comma); - AppendObject(SerializeFrom, "f", instanaSpan, writer); - writer.Write(Comma); - AppendProperty(instanaSpan.Ec, "ec", writer); - writer.Write(CloseCurlyBrace); - } - private static void SerializeFrom(InstanaSpan instanaSpan, StreamWriter writer) - { - writer.Write("{\"e\":\""); - writer.Write(instanaSpan.F.E); - writer.Write("\"}"); + writer.WritePropertyName("ts"); + writer.WriteNumberValue(DateToUnixMillis(instanaSpan.Ts)); + + writer.WritePropertyName("d"); + writer.WriteNumberValue(instanaSpan.D / 10_000L); + + SerializeData(instanaSpan, writer); + + writer.WritePropertyName("f"); + writer.WriteStartObject(); + + writer.WritePropertyName("e"); + writer.WriteStringValue(instanaSpan.F.E); + + writer.WriteEndObject(); + + writer.WritePropertyName("ec"); + writer.WriteNumberValue(instanaSpan.Ec); + + writer.WriteEndObject(); } private static long DateToUnixMillis(long timeStamp) => (timeStamp - UnixZeroTime) / 10_000; - private static void SerializeTags(InstanaSpan instanaSpan, StreamWriter writer) => - SerializeTagsLogic(instanaSpan.Data.Tags, writer); - - private static void SerializeTagsLogic(Dictionary? tags, StreamWriter writer) + private static void SerializeTags(Dictionary? tags, Utf8JsonWriter writer) { - writer.Write(OpenCurlyBrace); - if (tags == null) + if (tags == null || tags.Count < 1) { return; } - using (var enumerator = tags.GetEnumerator()) + writer.WritePropertyName(InstanaExporterConstants.TagsField); + writer.WriteStartObject(); + + using var enumerator = tags.GetEnumerator(); + + try { - byte i = 0; - try - { - while (enumerator.MoveNext()) - { - if (i > 0) - { - writer.Write(Comma); - } - else - { - i = 1; - } - - writer.Write(Quote); - writer.Write(enumerator.Current.Key); - writer.Write(QuoteColonQuote); - writer.Write(enumerator.Current.Value); - writer.Write(Quote); - } - } - catch (InvalidOperationException) + while (enumerator.MoveNext()) { - // if the collection gets modified while serializing, we might get a collision. - // There is no good way of preventing this and continuing normally except locking - // which needs investigation + writer.WritePropertyName(enumerator.Current.Key); + writer.WriteStringValue(enumerator.Current.Value); } } + catch (InvalidOperationException) + { + // If the collection gets modified while serializing, we might get a collision. + // There is no good way of preventing this and continuing normally except locking. + } - writer.Write(CloseCurlyBrace); - } - - private static void AppendProperty(string? value, string? name, StreamWriter json) - { - json.Write(Quote); - json.Write(name); - json.Write(QuoteColonQuote); - json.Write(value); - json.Write(Quote); - } - - private static void AppendProperty(long value, string name, StreamWriter json) - { - json.Write(Quote); - json.Write(name); - json.Write(QuoteColon); - json.Write(value.ToString(CultureInfo.InvariantCulture)); + writer.WriteEndObject(); } - private static void AppendObject(Action valueFunction, string name, InstanaSpan instanaSpan, StreamWriter json) + private static void AppendProperty(string? value, string name, Utf8JsonWriter json) { - json.Write(Quote); - json.Write(name); - json.Write(QuoteColon); - valueFunction(instanaSpan, json); + json.WritePropertyName(name); + json.WriteStringValue(value); } - private static void SerializeData(InstanaSpan instanaSpan, StreamWriter writer) + private static void SerializeData(InstanaSpan instanaSpan, Utf8JsonWriter writer) { - writer.Write(OpenCurlyBrace); if (instanaSpan.Data.Values == null) { return; } + writer.WritePropertyName("data"); + writer.WriteStartObject(); + using (var enumerator = instanaSpan.Data.Values.GetEnumerator()) { - byte i = 0; try { while (enumerator.MoveNext()) { - if (i > 0) - { - writer.Write(Comma); - } - else - { - i = 1; - } - - writer.Write(Quote); - writer.Write(enumerator.Current.Key); - writer.Write(QuoteColonQuote); - writer.Write(enumerator.Current.Value.ToString()); - writer.Write(Quote); + writer.WritePropertyName(enumerator.Current.Key); + writer.WriteStringValue(enumerator.Current.Value.ToString()); } } catch (InvalidOperationException) @@ -193,73 +130,41 @@ private static void SerializeData(InstanaSpan instanaSpan, StreamWriter writer) } } - if (instanaSpan.Data.Tags.Count > 0) - { - writer.Write(Comma); - - // Serialize tags - AppendObject(SerializeTags, InstanaExporterConstants.TagsField, instanaSpan, writer); - } + SerializeTags(instanaSpan.Data.Tags, writer); - if (instanaSpan.Data.Events.Count > 0) + if (instanaSpan.Data.Events is { Count: > 0 } events) { - writer.Write(Comma); + writer.WritePropertyName(InstanaExporterConstants.EventsField); + writer.WriteStartArray(); - // Serialize events - AppendObject(SerializeEvents, InstanaExporterConstants.EventsField, instanaSpan, writer); + SerializeEvents(events, writer); + + writer.WriteEndArray(); } - writer.Write(CloseCurlyBrace); + writer.WriteEndObject(); } - private static void SerializeEvents(InstanaSpan instanaSpan, StreamWriter writer) + private static void SerializeEvents(List events, Utf8JsonWriter writer) { - if (instanaSpan.Data.Events == null) - { - return; - } + using var enumerator = events.GetEnumerator(); - using var enumerator = instanaSpan.Data.Events.GetEnumerator(); - byte i = 0; try { - writer.Write("["); while (enumerator.MoveNext()) { - if (i > 0) - { - writer.Write(Comma); - } - else - { - i = 1; - } + writer.WriteStartObject(); - writer.Write(OpenCurlyBrace); - writer.Write(Quote); - writer.Write(InstanaExporterConstants.EventNameField); - writer.Write(QuoteColonQuote); - writer.Write(enumerator.Current.Name); - writer.Write(QuoteCommaQuote); - writer.Write(InstanaExporterConstants.EventTimestampField); - writer.Write(QuoteColonQuote); - writer.Write(DateToUnixMillis(enumerator.Current.Ts).ToString(CultureInfo.InvariantCulture)); - writer.Write(Quote); - - if (enumerator.Current.Tags.Count > 0) - { - writer.Write(Comma); - writer.Write(Quote); - writer.Write(InstanaExporterConstants.TagsField); - writer.Write(Quote); - writer.Write(Colon); - SerializeTagsLogic(enumerator.Current.Tags, writer); - } + writer.WritePropertyName(InstanaExporterConstants.EventNameField); + writer.WriteStringValue(enumerator.Current.Name); - writer.Write(CloseCurlyBrace); - } + writer.WritePropertyName(InstanaExporterConstants.EventTimestampField); + writer.WriteStringValue(DateToUnixMillis(enumerator.Current.Ts).ToString(CultureInfo.InvariantCulture)); + + SerializeTags(enumerator.Current.Tags, writer); - writer.Write("]"); + writer.WriteEndObject(); + } } catch (InvalidOperationException) { diff --git a/src/OpenTelemetry.Exporter.Instana/Implementation/Transport.cs b/src/OpenTelemetry.Exporter.Instana/Implementation/Transport.cs index 39172db7ff..4df38e372f 100644 --- a/src/OpenTelemetry.Exporter.Instana/Implementation/Transport.cs +++ b/src/OpenTelemetry.Exporter.Instana/Implementation/Transport.cs @@ -8,6 +8,7 @@ using System.Net.Http; #endif using System.Net.Http.Headers; +using System.Text.Json; namespace OpenTelemetry.Exporter.Instana.Implementation; @@ -41,38 +42,33 @@ public bool Send(List batch) try { - using var sendBuffer = new MemoryStream(buffer); - using var writer = new StreamWriter(sendBuffer); - writer.Write("{\"spans\":["); + using var stream = new MemoryStream(buffer); + using var writer = new Utf8JsonWriter(stream); + + writer.WriteStartObject(); + writer.WritePropertyName("spans"); + writer.WriteStartArray(); int maxBatchSize = this.options.BatchExportProcessorOptions.MaxExportBatchSize; + int written = 0; using var enumerator = batch.GetEnumerator(); - int written = 0; - - while (sendBuffer.Position < MultiSpanBufferLimit && written < maxBatchSize && enumerator.MoveNext()) + while ((writer.BytesCommitted + writer.BytesPending) < MultiSpanBufferLimit && written < maxBatchSize && enumerator.MoveNext()) { - if (written > 0) - { - writer.Write(','); - } - - InstanaSpanSerializer.SerializeToStreamWriter(enumerator.Current, writer); - - writer.Flush(); - + InstanaSpanSerializer.Serialize(enumerator.Current, writer); written++; } - writer.Write("]}"); + writer.WriteEndArray(); + writer.WriteEndObject(); writer.Flush(); - var length = sendBuffer.Position; - sendBuffer.Position = 0; - sendBuffer.SetLength(length); + var length = stream.Position; + stream.Position = 0; + stream.SetLength(length); - using var content = new StreamContent(sendBuffer, (int)length); + using var content = new StreamContent(stream, (int)length); content.Headers.ContentType = MediaType; using var message = new HttpRequestMessage(HttpMethod.Post, this.bundleUri) diff --git a/src/OpenTelemetry.Exporter.Instana/OpenTelemetry.Exporter.Instana.csproj b/src/OpenTelemetry.Exporter.Instana/OpenTelemetry.Exporter.Instana.csproj index f18ce9759c..4617c2e264 100644 --- a/src/OpenTelemetry.Exporter.Instana/OpenTelemetry.Exporter.Instana.csproj +++ b/src/OpenTelemetry.Exporter.Instana/OpenTelemetry.Exporter.Instana.csproj @@ -23,6 +23,10 @@ + + + + diff --git a/src/OpenTelemetry.Resources.Azure/OpenTelemetry.Resources.Azure.csproj b/src/OpenTelemetry.Resources.Azure/OpenTelemetry.Resources.Azure.csproj index 8b45309066..a2ee6c923c 100644 --- a/src/OpenTelemetry.Resources.Azure/OpenTelemetry.Resources.Azure.csproj +++ b/src/OpenTelemetry.Resources.Azure/OpenTelemetry.Resources.Azure.csproj @@ -22,8 +22,8 @@ - - + + diff --git a/src/OpenTelemetry.Resources.Gcp/OpenTelemetry.Resources.Gcp.csproj b/src/OpenTelemetry.Resources.Gcp/OpenTelemetry.Resources.Gcp.csproj index 10e099fa93..24c4ddab5b 100644 --- a/src/OpenTelemetry.Resources.Gcp/OpenTelemetry.Resources.Gcp.csproj +++ b/src/OpenTelemetry.Resources.Gcp/OpenTelemetry.Resources.Gcp.csproj @@ -19,8 +19,8 @@ - - + + diff --git a/test/OpenTelemetry.Exporter.Instana.Tests/InstanaExporterTests.cs b/test/OpenTelemetry.Exporter.Instana.Tests/InstanaExporterTests.cs index 4c0ab247a9..a9caaa5282 100644 --- a/test/OpenTelemetry.Exporter.Instana.Tests/InstanaExporterTests.cs +++ b/test/OpenTelemetry.Exporter.Instana.Tests/InstanaExporterTests.cs @@ -348,6 +348,58 @@ public async Task Export_WithCustomHttpClient() Assert.Equal(1, handler.InvocationCount); } + [Fact] + public async Task Export_EncodesJsonCorrectly() + { + // Arrange + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var handler = new TestHttpMessageHandler(tcs); + using var httpClient = new HttpClient(handler); + + var options = new InstanaExporterOptions() + { + AgentKey = "instana-agent-key", + EndpointUri = new Uri("http://localhost:42699"), + HttpClientFactory = () => httpClient, + }; + + var processor = DefaultActivityProcessor.CreateDefault(); + + using var exporter = new InstanaExporter(options, processor); + + using var activity = new Activity("my \"quoted\" operation"); + activity.SetStatus(ActivityStatusCode.Error, "line1\r\n\"line2\""); + activity.SetTag("http.route", "/orders/\"id\""); + activity.AddEvent( + new ActivityEvent( + "event \"name\"", + DateTimeOffset.UtcNow, + [new("detail", "line1\r\n\"line2\"")])); + + Activity[] activities = [activity]; + var batch = new Batch(activities, activities.Length); + + // Act + var result = exporter.Export(batch); + + // Assert + Assert.Equal(ExportResult.Success, result); + + var actual = await WaitForExportAsync(tcs); + + using var document = JsonDocument.Parse(actual); + var exportedSpan = document.RootElement.GetProperty("spans").EnumerateArray().Single(); + var data = exportedSpan.GetProperty("data"); + var exportedEvent = data.GetProperty("events").EnumerateArray().Single(); + + Assert.Equal("my \"quoted\" operation", data.GetProperty("operation").GetString()); + Assert.Equal("line1\r\n\"line2\"", data.GetProperty("error_detail").GetString()); + Assert.Equal("/orders/\"id\"", data.GetProperty("tags").GetProperty("http.route").GetString()); + Assert.Equal("event \"name\"", exportedEvent.GetProperty("name").GetString()); + Assert.Equal("line1\r\n\"line2\"", exportedEvent.GetProperty("tags").GetProperty("detail").GetString()); + } + private static async Task WaitForExportAsync(TaskCompletionSource completionSource) { var timeout = ExportTimeout; diff --git a/test/OpenTelemetry.Exporter.Instana.Tests/InstanaSpanSerializerTests.cs b/test/OpenTelemetry.Exporter.Instana.Tests/InstanaSpanSerializerTests.cs index e0a1f1d5e3..ceead2b0a3 100644 --- a/test/OpenTelemetry.Exporter.Instana.Tests/InstanaSpanSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Instana.Tests/InstanaSpanSerializerTests.cs @@ -48,17 +48,16 @@ public static void SerializeToStreamWriterAsync() ]; InstanaSpanTest? span; - using (var sendBuffer = new MemoryStream()) + using (var stream = new MemoryStream()) { - using var writer = new StreamWriter(sendBuffer); - InstanaSpanSerializer.SerializeToStreamWriter(instanaOtelSpan, writer); + using var writer = new Utf8JsonWriter(stream); + + InstanaSpanSerializer.Serialize(instanaOtelSpan, writer); writer.Flush(); - var length = sendBuffer.Position; - sendBuffer.Position = 0; - sendBuffer.SetLength(length); + stream.Position = 0; - span = JsonSerializer.Deserialize(sendBuffer, SerializerOptions); + span = JsonSerializer.Deserialize(stream, SerializerOptions); } Assert.NotNull(span);