Skip to content

Commit 7d20c31

Browse files
authored
[Exporter.Geneva] Add httpUrl for HTTP server spans (#2818)
1 parent a68ed98 commit 7d20c31

4 files changed

Lines changed: 326 additions & 0 deletions

File tree

src/OpenTelemetry.Exporter.Geneva/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
set using the `PrivatePreviewLogMessagePackStringSizeLimit=<CharCount>`
88
connection string parameter.
99
([#2813](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2813))
10+
* Add httpUrl for HTTP server spans mapped from multiple attributes.
11+
([#2818](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2818))
1012

1113
## 1.12.0
1214

src/OpenTelemetry.Exporter.Geneva/Internal/MsgPack/MsgPackTraceExporter.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Diagnostics;
88
using System.Globalization;
99
using System.Runtime.InteropServices;
10+
using System.Text;
1011
using OpenTelemetry.Exporter.Geneva.Transports;
1112
using OpenTelemetry.Internal;
1213

@@ -32,14 +33,29 @@ internal sealed class MsgPackTraceExporter : MsgPackExporter, IDisposable
3233
["messaging.url"] = "messagingUrl",
3334
};
3435

36+
internal static readonly Dictionary<string, int> CS40_PART_B_HTTPURL_MAPPING_DICTIONARY = new()
37+
{
38+
// Mapping from HTTP semconv to httpUrl
39+
// Combination of url.scheme, server.address, server.port, url.path and url.query attributes for HTTP server spans
40+
["url.scheme"] = 0,
41+
["server.address"] = 1,
42+
["server.port"] = 2,
43+
["url.path"] = 3,
44+
["url.query"] = 4,
45+
};
46+
3547
#if NET
3648
internal static readonly FrozenDictionary<string, string> CS40_PART_B_MAPPING = CS40_PART_B_MAPPING_DICTIONARY.ToFrozenDictionary();
49+
internal static readonly FrozenDictionary<string, int> CS40_PART_B_HTTPURL_MAPPING = CS40_PART_B_HTTPURL_MAPPING_DICTIONARY.ToFrozenDictionary();
3750
#else
3851
internal static readonly Dictionary<string, string> CS40_PART_B_MAPPING = CS40_PART_B_MAPPING_DICTIONARY;
52+
internal static readonly Dictionary<string, int> CS40_PART_B_HTTPURL_MAPPING = CS40_PART_B_HTTPURL_MAPPING_DICTIONARY;
3953
#endif
4054

4155
internal readonly ThreadLocal<byte[]> Buffer = new();
4256

57+
internal readonly ThreadLocal<object?[]> HttpUrlParts = new();
58+
4359
#if NET
4460
internal readonly FrozenSet<string>? CustomFields;
4561

@@ -243,6 +259,7 @@ public void Dispose()
243259
{
244260
(this.dataTransport as IDisposable)?.Dispose();
245261
this.Buffer.Dispose();
262+
this.HttpUrlParts.Dispose();
246263
}
247264
catch (Exception ex)
248265
{
@@ -252,6 +269,50 @@ public void Dispose()
252269
this.isDisposed = true;
253270
}
254271

272+
internal static bool CacheIfPartOfHttpUrl(KeyValuePair<string, object?> entry, object?[] httpUrlParts)
273+
{
274+
if (CS40_PART_B_HTTPURL_MAPPING.TryGetValue(entry.Key, out var index))
275+
{
276+
if (index < httpUrlParts.Length)
277+
{
278+
httpUrlParts[index] = entry.Value;
279+
return true;
280+
}
281+
}
282+
283+
return false;
284+
}
285+
286+
internal static string? GetHttpUrl(object?[] httpUrlParts)
287+
{
288+
// OpenTelemetry Semantic Convention: https://github.com/open-telemetry/semantic-conventions/blob/v1.28.0/docs/http/http-spans.md#http-server-semantic-conventions
289+
var scheme = httpUrlParts[0]?.ToString() ?? string.Empty; // 0 => CS40_PART_B_HTTPURL_MAPPING["url.scheme"]
290+
var address = httpUrlParts[1]?.ToString() ?? string.Empty; // 1 => CS40_PART_B_HTTPURL_MAPPING["server.address"]
291+
var port = httpUrlParts[2]?.ToString(); // 2 => CS40_PART_B_HTTPURL_MAPPING["server.port"]
292+
port = port != null ? $":{port}" : string.Empty;
293+
var path = httpUrlParts[3]?.ToString() ?? string.Empty; // 3 => CS40_PART_B_HTTPURL_MAPPING["url.path"]
294+
var query = httpUrlParts[4]?.ToString(); // 4 => CS40_PART_B_HTTPURL_MAPPING["url.query"]
295+
query = query != null ? $"?{query}" : string.Empty;
296+
297+
var length = scheme.Length + Uri.SchemeDelimiter.Length + address.Length + port.Length + path.Length + query.Length;
298+
299+
// No URL elements found, i.e. no scheme, no address, no port, no path, no query
300+
if (length == Uri.SchemeDelimiter.Length)
301+
{
302+
return null;
303+
}
304+
305+
var urlStringBuilder = new StringBuilder(length)
306+
.Append(scheme)
307+
.Append(Uri.SchemeDelimiter)
308+
.Append(address)
309+
.Append(port)
310+
.Append(path)
311+
.Append(query);
312+
313+
return urlStringBuilder.ToString();
314+
}
315+
255316
internal ArraySegment<byte> SerializeActivity(Activity activity)
256317
{
257318
var buffer = this.Buffer.Value;
@@ -367,8 +428,27 @@ internal ArraySegment<byte> SerializeActivity(Activity activity)
367428
var isStatusSuccess = true;
368429
string? statusDescription = null;
369430

431+
var isServerActivity = activity.Kind == ActivityKind.Server;
432+
var httpUrlParts = this.HttpUrlParts.Value ?? new object?[CS40_PART_B_HTTPURL_MAPPING.Count];
433+
if (isServerActivity)
434+
{
435+
if (this.HttpUrlParts.Value == null)
436+
{
437+
this.HttpUrlParts.Value = httpUrlParts;
438+
}
439+
else
440+
{
441+
Array.Clear(httpUrlParts, 0, httpUrlParts.Length);
442+
}
443+
}
444+
370445
foreach (ref readonly var entry in activity.EnumerateTagObjects())
371446
{
447+
if (isServerActivity && CacheIfPartOfHttpUrl(entry, httpUrlParts))
448+
{
449+
continue; // Skip this entry, since it is part of httpUrl.
450+
}
451+
372452
// TODO: check name collision
373453
if (CS40_PART_B_MAPPING.TryGetValue(entry.Key, out var replacementKey))
374454
{
@@ -393,6 +473,18 @@ internal ArraySegment<byte> SerializeActivity(Activity activity)
393473
cntFields += 1;
394474
}
395475

476+
if (isServerActivity)
477+
{
478+
var httpUrl = GetHttpUrl(httpUrlParts);
479+
if (httpUrl != null)
480+
{
481+
// If the activity is a server activity and has http.url, we need to add it as a dedicated field.
482+
cursor = MessagePackSerializer.SerializeAsciiString(buffer, cursor, "httpUrl");
483+
cursor = MessagePackSerializer.SerializeUnicodeString(buffer, cursor, httpUrl);
484+
cntFields += 1;
485+
}
486+
}
487+
396488
if (hasEnvProperties)
397489
{
398490
// Iteration #2 - Get all "other" fields and collapse them into single field

test/OpenTelemetry.Exporter.Geneva.Tests/GenevaTraceExporterTests.cs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,89 @@ public void GenevaTraceExporter_Serialization_Success(bool hasTableNameMapping,
336336
}
337337
}
338338

339+
[Fact]
340+
public void GenevaTraceExporter_ServerSpan_HttpUrl_Success()
341+
{
342+
var path = string.Empty;
343+
Socket server = null;
344+
try
345+
{
346+
var invocationCount = 0;
347+
var exporterOptions = new GenevaExporterOptions();
348+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
349+
{
350+
exporterOptions.ConnectionString = "EtwSession=OpenTelemetry";
351+
}
352+
else
353+
{
354+
path = GetRandomFilePath();
355+
exporterOptions.ConnectionString = "Endpoint=unix:" + path;
356+
var endpoint = new UnixDomainSocketEndPoint(path);
357+
server = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.IP);
358+
server.Bind(endpoint);
359+
server.Listen(1);
360+
}
361+
362+
using var exporter = new MsgPackTraceExporter(exporterOptions);
363+
364+
var m_buffer = exporter.Buffer;
365+
366+
// Add an ActivityListener to serialize the activity and assert that it was valid on ActivityStopped event
367+
368+
// Set the ActivitySourceName to the unique value of the test method name to avoid interference with
369+
// the ActivitySource used by other unit tests.
370+
var sourceName = GetTestMethodName();
371+
372+
using var listener = new ActivityListener();
373+
listener.ShouldListenTo = (activitySource) => activitySource.Name == sourceName;
374+
listener.Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllDataAndRecorded;
375+
listener.ActivityStopped = (activity) =>
376+
{
377+
_ = exporter.SerializeActivity(activity);
378+
var fluentdData = MessagePack.MessagePackSerializer.Deserialize<object>(m_buffer.Value, MessagePack.Resolvers.ContractlessStandardResolver.Options);
379+
this.AssertHttpUrlForActivity(exporterOptions, fluentdData, activity);
380+
invocationCount++;
381+
};
382+
ActivitySource.AddActivityListener(listener);
383+
384+
var source = new ActivitySource(sourceName);
385+
386+
// HTTP semconv: Combination of url.scheme, server.address, server.port, url.path and url.query
387+
// attributes for HTTP server spans.
388+
using (var parent = source.StartActivity("HttpIn", ActivityKind.Server))
389+
{
390+
parent.SetTag("http.request.method", "GET");
391+
parent.SetTag("url.scheme", "https");
392+
parent.SetTag("server.address", "localhost");
393+
parent.SetTag("server.port", 443);
394+
parent.SetTag("url.path", "/wiki/Rabbit");
395+
396+
// HTTP semconv: url.full attribute for HTTP client spans.
397+
using (var child = source.StartActivity("HttpOut", ActivityKind.Client))
398+
{
399+
child.SetTag("http.request.method", "GET");
400+
child.SetTag("url.full", "https://www.wikipedia.org/wiki/Rabbit?id=7");
401+
child.SetTag("http.status_code", 404);
402+
}
403+
404+
parent?.SetTag("http.response.status_code", 200);
405+
}
406+
407+
Assert.Equal(2, invocationCount);
408+
}
409+
finally
410+
{
411+
server?.Dispose();
412+
try
413+
{
414+
File.Delete(path);
415+
}
416+
catch
417+
{
418+
}
419+
}
420+
}
421+
339422
[SkipUnlessPlatformMatchesFact(TestPlatform.Linux)]
340423
public void GenevaTraceExporter_Constructor_Missing_Agent_Linux()
341424
{
@@ -778,4 +861,70 @@ private void AssertFluentdForwardModeForActivity(GenevaExporterOptions exporterO
778861

779862
customChecksForActivity?.Invoke(mapping);
780863
}
864+
865+
private void AssertHttpUrlForActivity(GenevaExporterOptions exporterOptions, object fluentdData, Activity activity)
866+
{
867+
/* Fluentd Forward Mode:
868+
[
869+
"Span",
870+
[
871+
[ <timestamp>, { "env_ver": "4.0", ... } ]
872+
],
873+
{ "TimeFormat": "DateTime" }
874+
]
875+
*/
876+
877+
var signal = (fluentdData as object[])[0] as string;
878+
var TimeStampAndMappings = ((fluentdData as object[])[1] as object[])[0];
879+
var timeStamp = (DateTime)(TimeStampAndMappings as object[])[0];
880+
var mapping = (TimeStampAndMappings as object[])[1] as Dictionary<object, object>;
881+
882+
Assert.Equal((byte)activity.Kind, mapping["kind"]);
883+
var tags = activity.TagObjects.ToDictionary(tag => tag.Key, tag => tag.Value);
884+
885+
if (activity.Kind == ActivityKind.Server)
886+
{
887+
// For HTTP server spans, they might contain these attributes for URL:
888+
// Unstable HTTP semconv: Combination of http.scheme, net.host.name, net.host.port, and http.target attributes.
889+
// Stable HTTP semconv: Combination of url.scheme, server.address, server.port, url.path and url.query attributes.
890+
// They will be mapped to httpUrl by Geneva exporter in MsgPackTraceExporter.
891+
Assert.DoesNotContain("http.scheme", mapping.Keys);
892+
Assert.DoesNotContain("net.host.name", mapping.Keys);
893+
Assert.DoesNotContain("net.host.port", mapping.Keys);
894+
Assert.DoesNotContain("http.target", mapping.Keys);
895+
Assert.DoesNotContain("url.scheme", mapping.Keys);
896+
Assert.DoesNotContain("server.address", mapping.Keys);
897+
Assert.DoesNotContain("server.port", mapping.Keys);
898+
Assert.DoesNotContain("url.path", mapping.Keys);
899+
Assert.DoesNotContain("url.query", mapping.Keys);
900+
901+
Assert.Equal("GET", mapping["httpMethod"]);
902+
Assert.Equal("https://localhost:443/wiki/Rabbit", mapping["httpUrl"]);
903+
904+
Assert.DoesNotContain("http.status_code", mapping.Keys);
905+
Assert.DoesNotContain("http.response.status_code", mapping.Keys);
906+
Assert.Equal(200, Convert.ToInt32(mapping["httpStatusCode"]));
907+
}
908+
else if (activity.Kind == ActivityKind.Client)
909+
{
910+
// For HTTP client spans, they might contain this attribute for URL:
911+
// Unstable HTTP semconv: http.url attribute.
912+
// Stable HTTP semconv: url.full attribute.
913+
// They will be mapped to httpUrl by Geneva exporter in MsgPackTraceExporter.
914+
Assert.DoesNotContain("http.url", mapping.Keys);
915+
Assert.DoesNotContain("url.full", mapping.Keys);
916+
917+
Assert.Equal("GET", mapping["httpMethod"]);
918+
919+
Assert.Equal(tags["url.full"], mapping["httpUrl"]);
920+
921+
Assert.DoesNotContain("http.status_code", mapping.Keys);
922+
Assert.DoesNotContain("http.response.status_code", mapping.Keys);
923+
Assert.Equal(404, Convert.ToInt32(mapping["httpStatusCode"]));
924+
}
925+
else
926+
{
927+
throw new InvalidOperationException($"Unexpected ActivityKind: {activity.Kind}. Expected either Server or Client.");
928+
}
929+
}
781930
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using OpenTelemetry.Exporter.Geneva.MsgPack;
5+
using Xunit;
6+
7+
namespace OpenTelemetry.Exporter.Geneva.Tests;
8+
9+
public class MsgPackTraceExporterTests
10+
{
11+
[Fact]
12+
public void CacheIfPartOfHttpUrl_KeyPresent_IndexInRange_SetsValueAndReturnsTrue()
13+
{
14+
var entry = new KeyValuePair<string, object?>("url.scheme", "https");
15+
var arr = new object?[MsgPackTraceExporter.CS40_PART_B_HTTPURL_MAPPING.Count];
16+
var result = MsgPackTraceExporter.CacheIfPartOfHttpUrl(entry, arr);
17+
Assert.True(result);
18+
Assert.Equal("https", arr[0]);
19+
}
20+
21+
[Fact]
22+
public void CacheIfPartOfHttpUrl_KeyPresent_IndexOutOfRange_ReturnsFalse()
23+
{
24+
var entry = new KeyValuePair<string, object?>("url.scheme", "https");
25+
var arr = Array.Empty<object?>(); // zero-length array
26+
var result = MsgPackTraceExporter.CacheIfPartOfHttpUrl(entry, arr);
27+
Assert.False(result);
28+
}
29+
30+
[Fact]
31+
public void CacheIfPartOfHttpUrl_KeyNotPresent_ReturnsFalse()
32+
{
33+
var entry = new KeyValuePair<string, object?>("not.a.key", "value");
34+
var arr = new object?[MsgPackTraceExporter.CS40_PART_B_HTTPURL_MAPPING.Count];
35+
var result = MsgPackTraceExporter.CacheIfPartOfHttpUrl(entry, arr);
36+
Assert.False(result);
37+
}
38+
39+
[Fact]
40+
public void CacheIfPartOfHttpUrl_NullValue_SetsNull()
41+
{
42+
var entry = new KeyValuePair<string, object?>("url.scheme", null);
43+
var arr = new object?[MsgPackTraceExporter.CS40_PART_B_HTTPURL_MAPPING.Count];
44+
var result = MsgPackTraceExporter.CacheIfPartOfHttpUrl(entry, arr);
45+
Assert.True(result);
46+
Assert.Null(arr[0]);
47+
}
48+
49+
[Theory]
50+
[InlineData("", "", "", "", null)]
51+
[InlineData("http", "host", "", "", "http://host")]
52+
[InlineData("http", "host", "8080", "/x", "http://host:8080/x")]
53+
[InlineData("https", "server", "443", "/api", "https://server:443/api")]
54+
[InlineData("http", "host", "", "/x?y=1", "http://host/x?y=1")]
55+
public void GetHttpUrl_ReturnsExpectedUrl(string scheme, string hostOrAddress, string port, string pathAndQuery, string? expected)
56+
{
57+
var arr = new object?[MsgPackTraceExporter.CS40_PART_B_MAPPING_DICTIONARY.Count];
58+
arr[0] = scheme;
59+
arr[1] = hostOrAddress;
60+
arr[2] = string.IsNullOrEmpty(port) ? null : port;
61+
if (!string.IsNullOrEmpty(pathAndQuery) && pathAndQuery.Contains('?'))
62+
{
63+
var split = pathAndQuery.Split(['?'], 2);
64+
arr[3] = split[0];
65+
arr[4] = split.Length > 1 ? split[1] : null;
66+
}
67+
else
68+
{
69+
arr[3] = string.IsNullOrEmpty(pathAndQuery) ? null : pathAndQuery;
70+
}
71+
72+
var url = MsgPackTraceExporter.GetHttpUrl(arr);
73+
Assert.Equal(expected, url);
74+
}
75+
76+
[Fact]
77+
public void GetHttpUrl_UnknownMethod_ReturnsNull()
78+
{
79+
var arr = new object?[MsgPackTraceExporter.CS40_PART_B_HTTPURL_MAPPING.Count];
80+
var url = MsgPackTraceExporter.GetHttpUrl(arr);
81+
Assert.Null(url);
82+
}
83+
}

0 commit comments

Comments
 (0)