Skip to content

Commit 21e7265

Browse files
test: extract OTLP payload normalization into OtlpSnapshotHelper
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3796134 commit 21e7265

2 files changed

Lines changed: 316 additions & 186 deletions

File tree

tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/Helpers/OtlpSnapshotHelper.cs

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
#nullable enable
77

88
using System;
9+
using System.Collections.Generic;
10+
using System.Linq;
911
using System.Net.Http;
12+
using System.Text.RegularExpressions;
1013
using System.Threading.Tasks;
1114
using Datadog.Trace.TestHelpers;
1215
using Datadog.Trace.Vendors.Newtonsoft.Json.Linq;
16+
using FluentAssertions;
1317
using VerifyTests;
1418

1519
namespace Datadog.Trace.ClrProfiler.IntegrationTests.Helpers
@@ -52,6 +56,10 @@ private static readonly (string From, string To)[] ProtobufToJsonEnumMappings =
5256
("\"code\": \"STATUS_CODE_ERROR\"", "\"code\": 2"),
5357
};
5458

59+
private static readonly Regex TraceIdRegex = new(@"^([a-fA-F0-9]{32})$");
60+
61+
private static readonly Regex SpanIdRegex = new(@"^([a-fA-F0-9]{16})$");
62+
5563
public static void AddProtobufToJsonScrubbers(VerifySettings settings)
5664
{
5765
foreach (var (from, to) in ProtobufToJsonFieldNameMappings)
@@ -135,5 +143,307 @@ public static async Task<JToken> WaitForTestAgentDataAsync(string url, int timeo
135143
var finalJson = await finalResponse.Content.ReadAsStringAsync();
136144
return JToken.Parse(finalJson);
137145
}
146+
147+
/// <summary>
148+
/// Replaces the resource attributes that change between runs or between machines.
149+
/// </summary>
150+
/// <param name="tracesRequests">The captured OTLP requests.</param>
151+
/// <param name="names">The field-name casing to use.</param>
152+
public static void NormalizeResourceAttributes(JToken tracesRequests, OtlpFieldNames names)
153+
{
154+
var stringValueKey = names.StringValue;
155+
156+
foreach (var attribute in tracesRequests.SelectTokens("$..resource.attributes[?(@.key == 'telemetry.sdk.version')]"))
157+
{
158+
attribute["value"]![stringValueKey] = "sdk-version";
159+
}
160+
161+
foreach (var attribute in tracesRequests.SelectTokens("$..resource.attributes[?(@.key == 'telemetry.sdk.name')]"))
162+
{
163+
attribute["value"]![stringValueKey] = "sdk-name";
164+
}
165+
166+
foreach (var attribute in tracesRequests.SelectTokens("$..resource.attributes[?(@.key == 'git.commit.sha')]"))
167+
{
168+
attribute["value"]![stringValueKey] = "normalized-git-commit-sha";
169+
}
170+
}
171+
172+
/// <summary>
173+
/// Asserts that the trace ids, span ids, and timestamps are well-formed, then replaces them
174+
/// with fixed placeholders so the payload is stable across runs.
175+
/// </summary>
176+
/// <param name="tracesRequests">The captured OTLP requests.</param>
177+
/// <param name="names">The field-name casing to use.</param>
178+
/// <param name="applicationStartTimeUnixNano">The time the sample application was started, used as a lower bound for span timestamps.</param>
179+
public static void NormalizeSpans(JToken tracesRequests, OtlpFieldNames names, long applicationStartTimeUnixNano)
180+
{
181+
var isJson = names.IsJson;
182+
var stringValueKey = names.StringValue;
183+
var traceIdKey = names.TraceId;
184+
var spanIdKey = names.SpanId;
185+
var parentSpanIdKey = names.ParentSpanId;
186+
var startTimeUnixNanoKey = names.StartTimeUnixNano;
187+
var endTimeUnixNanoKey = names.EndTimeUnixNano;
188+
var timeUnixNanoKey = names.TimeUnixNano;
189+
190+
foreach (var span in tracesRequests.SelectTokens("$..spans[*]"))
191+
{
192+
// Parse unstable information from the span
193+
string traceIdData = isJson ? span[traceIdKey]!.ToString()
194+
: ToTraceId(Convert.FromBase64String(span[traceIdKey]!.ToString()));
195+
string spanIdData = isJson ? span[spanIdKey]!.ToString()
196+
: ToSpanId(Convert.FromBase64String(span[spanIdKey]!.ToString()));
197+
var spanStartTimeUnixNano = long.Parse(span[startTimeUnixNanoKey]!.ToString());
198+
var spanEndTimeUnixNano = long.Parse(span[endTimeUnixNanoKey]!.ToString());
199+
200+
// Add strong assertions on unstable span information
201+
spanStartTimeUnixNano.Should().BeGreaterThanOrEqualTo(applicationStartTimeUnixNano);
202+
spanEndTimeUnixNano.Should().BeGreaterThanOrEqualTo(spanStartTimeUnixNano);
203+
traceIdData.Should().MatchRegex(TraceIdRegex);
204+
spanIdData.Should().MatchRegex(SpanIdRegex);
205+
if (span[parentSpanIdKey] is not null)
206+
{
207+
string? parentSpanIdData = isJson ? span[parentSpanIdKey]?.ToString()
208+
: ToSpanId(Convert.FromBase64String(span[parentSpanIdKey]!.ToString()));
209+
parentSpanIdData.Should().MatchRegex(SpanIdRegex);
210+
}
211+
212+
// Normalize the unstable span information for our snapshots
213+
span[startTimeUnixNanoKey] = "0";
214+
span[endTimeUnixNanoKey] = "0";
215+
span[traceIdKey] = "normalized-trace-id";
216+
span[spanIdKey] = "normalized-span-id";
217+
if (span[parentSpanIdKey] is not null)
218+
{
219+
span[parentSpanIdKey] = "normalized-parent-span-id";
220+
}
221+
222+
// Our JSON and Protobuf OTLP exporters differ in serialization behavior when there are no attributes.
223+
// Standardize them here by removing an empty array
224+
if (span["attributes"] is JArray attributes && attributes.Count == 0)
225+
{
226+
((JObject)span).Remove("attributes");
227+
}
228+
}
229+
230+
foreach (var attribute in tracesRequests.SelectTokens("$..spans[*].attributes[?(@.key == 'otel.trace_id')]"))
231+
{
232+
attribute["value"]![stringValueKey] = "normalized-otel-trace-id";
233+
}
234+
235+
foreach (var link in tracesRequests.SelectTokens("$..links[*]"))
236+
{
237+
if (isJson)
238+
{
239+
link[traceIdKey]!.ToString().Should().MatchRegex(TraceIdRegex);
240+
link[spanIdKey]!.ToString().Should().MatchRegex(SpanIdRegex);
241+
}
242+
243+
link[traceIdKey] = "normalized-trace-id";
244+
link[spanIdKey] = "normalized-span-id";
245+
}
246+
247+
foreach (var @event in tracesRequests.SelectTokens("$..events[*]"))
248+
{
249+
((JObject)@event).Remove(timeUnixNanoKey);
250+
((JObject)@event).AddFirst(new JProperty(timeUnixNanoKey, "0"));
251+
}
252+
}
253+
254+
/// <summary>
255+
/// Collapses every captured request into the first one. Asserts first that each request
256+
/// carries identical resource attributes and a single instrumentation scope, which holds for
257+
/// the Datadog SDK because it emits one application-level resource and does not yet track
258+
/// spans per instrumentation scope.
259+
/// </summary>
260+
/// <param name="tracesRequests">The captured OTLP requests.</param>
261+
/// <param name="names">The field-name casing to use.</param>
262+
/// <param name="sortSpans">Orders the merged spans. Defaults to ordering by span name.</param>
263+
/// <returns>The single merged request.</returns>
264+
public static JToken MergeDatadogRequests(
265+
JToken tracesRequests,
266+
OtlpFieldNames names,
267+
Func<IEnumerable<JToken>, IEnumerable<JToken>>? sortSpans = null)
268+
{
269+
var resourceSpansKey = names.ResourceSpans;
270+
var scopeSpansKey = names.ScopeSpans;
271+
272+
// First, for the DD SDK, assert that the resource attributes for all requests are identical
273+
// This is analogous to DD_SERVICE, DD_VERSION, DD_ENV, etc. that define
274+
// metadata for the telemetry at an application and host level.
275+
// This is different for OTel SDK application since the in-app code uses the SDK to create a
276+
// 2nd, completely distinct, Traces SDK instance
277+
JToken? previousResourceAttributes = null;
278+
foreach (var tracesRequest in tracesRequests)
279+
{
280+
tracesRequest[resourceSpansKey].Should().HaveCount(1);
281+
var resourceAttributes = tracesRequest[resourceSpansKey]![0]!["resource"]!["attributes"];
282+
283+
if (previousResourceAttributes is null)
284+
{
285+
previousResourceAttributes = resourceAttributes;
286+
}
287+
else
288+
{
289+
JToken.DeepEquals(previousResourceAttributes, resourceAttributes).Should().BeTrue();
290+
previousResourceAttributes = resourceAttributes;
291+
}
292+
}
293+
294+
// Next, assert that we only have a singular InstrumentationScope in each request.
295+
// In OpenTelemetry, an InstrumentationScope is a way to group spans by the library that produced them.
296+
// We should be respecting this for each library/ActivitySource, but right now the DD SDK doesn't
297+
// keep track of that information, so consolidate them into one single, empty InstrumentationScope.
298+
// TODO: Properly track spans per instrumentation scope.
299+
JArray? firstSpans = null;
300+
foreach (var tracesRequest in tracesRequests)
301+
{
302+
tracesRequest[resourceSpansKey]![0]![scopeSpansKey].Should().HaveCount(1);
303+
var spans = tracesRequest[resourceSpansKey]![0]![scopeSpansKey]![0]!["spans"] as JArray;
304+
305+
if (firstSpans is null)
306+
{
307+
firstSpans = spans;
308+
}
309+
else
310+
{
311+
foreach (var span in spans!)
312+
{
313+
firstSpans.Add(span);
314+
}
315+
}
316+
}
317+
318+
// Now re-order and trim down to one single request
319+
// This means the output is not a true 1:1 mapping of the input spans, but it's good enough for now
320+
// and will make the results stable.
321+
// Also, sort the spans by name to stabilize
322+
sortSpans ??= spans => spans.OrderBy(s => s["name"]!.ToString());
323+
var sortedSpans = new JArray(sortSpans(firstSpans!));
324+
tracesRequests[0]![resourceSpansKey]![0]![scopeSpansKey]![0]!["spans"] = sortedSpans;
325+
return tracesRequests[0]!;
326+
}
327+
328+
/// <summary>
329+
/// Sorts spans by name within each scope, leaving the request structure intact. Used when the
330+
/// payload comes from a real OTel SDK, which emits genuinely distinct instrumentation scopes.
331+
/// </summary>
332+
/// <param name="tracesRequests">The captured OTLP requests.</param>
333+
/// <param name="names">The field-name casing to use.</param>
334+
public static void SortSpansPerScope(JToken tracesRequests, OtlpFieldNames names)
335+
{
336+
foreach (var scopeSpan in tracesRequests.SelectTokens($"$..{names.ScopeSpans}[*]"))
337+
{
338+
if (scopeSpan["spans"] is JArray spansArray)
339+
{
340+
var sorted = new JArray(spansArray.OrderBy(s => s["name"]?.ToString()));
341+
scopeSpan["spans"] = sorted;
342+
}
343+
}
344+
}
345+
346+
/// <summary>
347+
/// Returns the string value of the first attribute matching any of <paramref name="keys"/>,
348+
/// or null when the span carries none of them. Accepts several keys because a tag's name
349+
/// changes with the semantic conventions in play, for example http.url versus url.full.
350+
/// </summary>
351+
/// <param name="span">The span to read from.</param>
352+
/// <param name="names">The field-name casing to use.</param>
353+
/// <param name="keys">The attribute keys to look for, in priority order.</param>
354+
/// <returns>The attribute value, or null when the span has none of the keys.</returns>
355+
public static string? GetAttributeStringValue(JToken span, OtlpFieldNames names, params string[] keys)
356+
{
357+
if (span["attributes"] is not JArray attributes)
358+
{
359+
return null;
360+
}
361+
362+
foreach (var key in keys)
363+
{
364+
foreach (var attribute in attributes)
365+
{
366+
if (attribute["key"]?.ToString() == key)
367+
{
368+
return attribute["value"]?[names.StringValue]?.ToString();
369+
}
370+
}
371+
}
372+
373+
return null;
374+
}
375+
376+
/// <summary>
377+
/// Sets a string attribute on a span, appending it when the span does not already have it.
378+
/// </summary>
379+
/// <param name="span">The span to modify.</param>
380+
/// <param name="names">The field-name casing to use.</param>
381+
/// <param name="key">The attribute key.</param>
382+
/// <param name="value">The attribute value.</param>
383+
public static void SetAttributeStringValue(JToken span, OtlpFieldNames names, string key, string value)
384+
{
385+
if (span["attributes"] is not JArray attributes)
386+
{
387+
attributes = new JArray();
388+
((JObject)span)["attributes"] = attributes;
389+
}
390+
391+
foreach (var attribute in attributes)
392+
{
393+
if (attribute["key"]?.ToString() == key)
394+
{
395+
attribute["value"] = new JObject { [names.StringValue] = value };
396+
return;
397+
}
398+
}
399+
400+
attributes.Add(new JObject
401+
{
402+
["key"] = key,
403+
["value"] = new JObject { [names.StringValue] = value },
404+
});
405+
}
406+
407+
/// <summary>
408+
/// Sorts every span's attribute array by key. Attribute order otherwise follows tag
409+
/// enumeration order, which is not guaranteed to be stable across runtimes.
410+
/// </summary>
411+
/// <param name="tracesRequests">The captured OTLP requests.</param>
412+
public static void SortSpanAttributes(JToken tracesRequests)
413+
{
414+
foreach (var span in tracesRequests.SelectTokens("$..spans[*]"))
415+
{
416+
if (span["attributes"] is JArray attributes)
417+
{
418+
((JObject)span)["attributes"] = new JArray(
419+
attributes.OrderBy(a => a["key"]?.ToString() ?? string.Empty, StringComparer.Ordinal));
420+
}
421+
}
422+
}
423+
424+
private static string ToHexString(byte[] bytes, int length)
425+
{
426+
bytes.Length.Should().Be(length);
427+
428+
var traceId = new byte[length * 2];
429+
for (int i = 0; i < length; i++)
430+
{
431+
traceId[2 * i] = (byte)(bytes[i] >> 4); // high 4 bits
432+
traceId[(2 * i) + 1] = (byte)(bytes[i] & 0x0F); // low 4 bits
433+
}
434+
435+
// Convert each nibble (0-15) to its hex character
436+
var result = new char[length * 2];
437+
for (int i = 0; i < length * 2; i++)
438+
{
439+
result[i] = (char)(traceId[i] < 10 ? '0' + traceId[i] : 'a' + traceId[i] - 10);
440+
}
441+
442+
return new string(result);
443+
}
444+
445+
private static string ToTraceId(byte[] bytes) => ToHexString(bytes, 16);
446+
447+
private static string ToSpanId(byte[] bytes) => ToHexString(bytes, 8);
138448
}
139449
}

0 commit comments

Comments
 (0)