diff --git a/tracer/src/Datadog.Trace/Agent/MessagePack/SpanMessagePackFormatter.cs b/tracer/src/Datadog.Trace/Agent/MessagePack/SpanMessagePackFormatter.cs index 73250936c9be..13dab77db260 100644 --- a/tracer/src/Datadog.Trace/Agent/MessagePack/SpanMessagePackFormatter.cs +++ b/tracer/src/Datadog.Trace/Agent/MessagePack/SpanMessagePackFormatter.cs @@ -68,6 +68,12 @@ internal class SpanMessagePackFormatter : IMessagePackFormatter private readonly byte[] _runtimeIdNameBytes = StringEncoding.UTF8.GetBytes(Trace.Tags.RuntimeId); private readonly byte[] _runtimeIdValueBytes = StringEncoding.UTF8.GetBytes(Tracer.RuntimeId); + // using a Lazy here to make sure we don't compute the value of the process tags too early in the life of the app, + // some values may need a bit of time to be accessible. + // With this construct, it should be queried after the first span(s) get closed, which should be late enough. + private readonly Lazy _processTagsValueBytes = new(() => StringEncoding.UTF8.GetBytes(ProcessTags.SerializedTags)); + private readonly byte[] _processTagsNameBytes = StringEncoding.UTF8.GetBytes(Tags.ProcessTags); + private readonly byte[] _environmentNameBytes = StringEncoding.UTF8.GetBytes(Trace.Tags.Env); private readonly byte[] _gitCommitShaNameBytes = StringEncoding.UTF8.GetBytes(Trace.Tags.GitCommitSha); private readonly byte[] _gitRepositoryUrlNameBytes = StringEncoding.UTF8.GetBytes(Trace.Tags.GitRepositoryUrl); @@ -603,6 +609,18 @@ private int WriteTags(ref byte[] bytes, int offset, in SpanModel model, ITagProc } } + // Process tags will be sent only once per buffer/payload (one payload can contain many chunks from different traces) + if (model.IsFirstSpanInChunk && model.TraceChunk.IsFirstChunkInPayload && model.TraceChunk.ShouldPropagateProcessTags) + { + var processTagsRawBytes = _processTagsValueBytes.Value; + if (processTagsRawBytes.Length > 0) + { + count++; + offset += MessagePackBinary.WriteStringBytes(ref bytes, offset, _processTagsNameBytes); + offset += MessagePackBinary.WriteStringBytes(ref bytes, offset, processTagsRawBytes); + } + } + // SCI tags will be sent only once per trace if (model.IsFirstSpanInChunk) { diff --git a/tracer/src/Datadog.Trace/Agent/MessagePack/TraceChunkModel.cs b/tracer/src/Datadog.Trace/Agent/MessagePack/TraceChunkModel.cs index eb3288c877e9..4e5dbb41540f 100644 --- a/tracer/src/Datadog.Trace/Agent/MessagePack/TraceChunkModel.cs +++ b/tracer/src/Datadog.Trace/Agent/MessagePack/TraceChunkModel.cs @@ -33,6 +33,8 @@ internal readonly struct TraceChunkModel public readonly int? SamplingPriority = null; + public readonly bool IsFirstChunkInPayload = false; + public readonly string? SamplingMechanism = null; public readonly double? AppliedSamplingRate = null; @@ -63,6 +65,8 @@ internal readonly struct TraceChunkModel public readonly ImmutableAzureAppServiceSettings? AzureAppServiceSettings = null; + public readonly bool ShouldPropagateProcessTags = false; + public readonly bool IsApmEnabled = true; /// @@ -70,8 +74,9 @@ internal readonly struct TraceChunkModel /// /// The spans that will be within this . /// Optional sampling priority to override the sampling priority. - public TraceChunkModel(in ArraySegment spans, int? samplingPriority = null) - : this(spans, TraceContext.GetTraceContext(spans), samplingPriority) + /// Indicates if this is the first trace chunk being written to the output buffer. + public TraceChunkModel(in ArraySegment spans, int? samplingPriority = null, bool isFirstChunkInPayload = false) + : this(spans, TraceContext.GetTraceContext(spans), samplingPriority, isFirstChunkInPayload) { // since all we have is an array of spans, use the trace context from the first span // to get the other values we need (sampling priority, origin, trace tags, etc) for now. @@ -80,11 +85,12 @@ public TraceChunkModel(in ArraySegment spans, int? samplingPriority = null } // used only to chain constructors - private TraceChunkModel(in ArraySegment spans, TraceContext? traceContext, int? samplingPriority) + private TraceChunkModel(in ArraySegment spans, TraceContext? traceContext, int? samplingPriority, bool isFirstChunkInPayload) : this(spans, traceContext?.RootSpan) { // sampling decision override takes precedence over TraceContext.SamplingPriority SamplingPriority = samplingPriority; + IsFirstChunkInPayload = isFirstChunkInPayload; if (traceContext is not null) { @@ -108,6 +114,7 @@ private TraceChunkModel(in ArraySegment spans, TraceContext? traceContext, { IsRunningInAzureAppService = settings.IsRunningInAzureAppService; AzureAppServiceSettings = settings.AzureAppServiceMetadata; + ShouldPropagateProcessTags = settings.PropagateProcessTags; IsApmEnabled = settings.ApmTracingEnabled; } diff --git a/tracer/src/Datadog.Trace/Agent/SpanBuffer.cs b/tracer/src/Datadog.Trace/Agent/SpanBuffer.cs index 66c142b0c8af..e0aa492931e8 100644 --- a/tracer/src/Datadog.Trace/Agent/SpanBuffer.cs +++ b/tracer/src/Datadog.Trace/Agent/SpanBuffer.cs @@ -91,7 +91,7 @@ public WriteStatus TryWrite(ArraySegment spans, ref byte[] temporaryBuffer // to get the other values we need (sampling priority, origin, trace tags, etc) for now. // the idea is that as we refactor further, we can pass more than just the spans, // and these values can come directly from the trace context. - var traceChunk = new TraceChunkModel(spans, samplingPriority); + var traceChunk = new TraceChunkModel(spans, samplingPriority, isFirstChunkInPayload: TraceCount == 0); // We don't know what the serialized size of the payload will be, // so we need to write to a temporary buffer first diff --git a/tracer/src/Datadog.Trace/Configuration/ConfigurationKeys.cs b/tracer/src/Datadog.Trace/Configuration/ConfigurationKeys.cs index 647f4537c6cf..735e3c3d502c 100644 --- a/tracer/src/Datadog.Trace/Configuration/ConfigurationKeys.cs +++ b/tracer/src/Datadog.Trace/Configuration/ConfigurationKeys.cs @@ -168,6 +168,11 @@ internal static partial class ConfigurationKeys /// public const string GrpcTags = "DD_TRACE_GRPC_TAGS"; + /// + /// Propagate the process tags in every supported payload + /// + public const string PropagateProcessTags = "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED"; + /// /// Configuration key for a map of services to rename. /// diff --git a/tracer/src/Datadog.Trace/Configuration/TracerSettings.cs b/tracer/src/Datadog.Trace/Configuration/TracerSettings.cs index 0007ac7880b8..a39e67e3167b 100644 --- a/tracer/src/Datadog.Trace/Configuration/TracerSettings.cs +++ b/tracer/src/Datadog.Trace/Configuration/TracerSettings.cs @@ -33,7 +33,7 @@ namespace Datadog.Trace.Configuration public record TracerSettings { private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); - private static readonly HashSet DefaultExperimentalFeatures = ["DD_TAGS"]; + private static readonly HashSet DefaultExperimentalFeatures = ["DD_TAGS", ConfigurationKeys.PropagateProcessTags]; private readonly IConfigurationTelemetry _telemetry; private readonly Lazy _fallbackApplicationName; @@ -102,6 +102,11 @@ internal TracerSettings(IConfigurationSource? source, IConfigurationTelemetry te string s => new HashSet(s.Split([','], StringSplitOptions.RemoveEmptyEntries)), }; + PropagateProcessTags = config + .WithKeys(ConfigurationKeys.PropagateProcessTags) + .AsBool(false) + || ExperimentalFeaturesEnabled.Contains(ConfigurationKeys.PropagateProcessTags); + GCPFunctionSettings = new ImmutableGCPFunctionSettings(source, _telemetry); IsRunningInGCPFunctions = GCPFunctionSettings.IsGCPFunction; @@ -742,6 +747,8 @@ not null when string.Equals(value, "otlp", StringComparison.OrdinalIgnoreCase) = internal HashSet ExperimentalFeaturesEnabled { get; } + internal bool PropagateProcessTags { get; } + internal OverrideErrorLog ErrorLog { get; } internal IConfigurationTelemetry Telemetry => _telemetry; diff --git a/tracer/src/Datadog.Trace/ProcessTags.cs b/tracer/src/Datadog.Trace/ProcessTags.cs new file mode 100644 index 000000000000..699f98396bc3 --- /dev/null +++ b/tracer/src/Datadog.Trace/ProcessTags.cs @@ -0,0 +1,72 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Datadog.Trace.Configuration; +using Datadog.Trace.Processors; +using Datadog.Trace.Util; + +namespace Datadog.Trace; + +internal static class ProcessTags +{ + public const string EntrypointName = "entrypoint.name"; + public const string EntrypointBasedir = "entrypoint.basedir"; + public const string EntrypointWorkdir = "entrypoint.workdir"; + + private static readonly Lazy LazySerializedTags = new(GetSerializedTags); + + public static string SerializedTags + { + get => LazySerializedTags.Value; + } + + /// + /// From the full path of a directory, get the name of the leaf directory. + /// + private static string GetLastPathSegment(string directoryPath) + { + // Path.GetFileName returns an empty string if the path ends with a '/'. + // We could use Path.TrimEndingDirectorySeparator instead of the trim here, but it's not available on .NET Framework + return Path.GetFileName(directoryPath.TrimEnd('\\').TrimEnd('/')); + } + + private static string GetSerializedTags() + { + // ⚠️ make sure entries are added in alphabetical order of keys + var tags = new List> + { + new(EntrypointBasedir, GetLastPathSegment(AppContext.BaseDirectory)), + new(EntrypointName, EntryAssemblyLocator.GetEntryAssembly()?.EntryPoint?.DeclaringType?.FullName), + // workdir can be changed by the code, but we consider that capturing the value when this is called is good enough + new(EntrypointWorkdir, GetLastPathSegment(Environment.CurrentDirectory)) + }; + + // then normalize values and put all tags in a string + var serializedTags = StringBuilderCache.Acquire(); + foreach (var kvp in tags) + { + if (!string.IsNullOrEmpty(kvp.Value)) + { + serializedTags.Append($"{kvp.Key}:{NormalizeTagValue(kvp.Value!)},"); + } + } + + serializedTags.Remove(serializedTags.Length - 1, length: 1); // remove last comma + return StringBuilderCache.GetStringAndRelease(serializedTags); + } + + private static string NormalizeTagValue(string tagValue) + { + // TraceUtil.NormalizeTag does almost exactly what we want, except it allows ':', + // which we don't want because we use it as a key/value separator. + return TraceUtil.NormalizeTag(tagValue).Replace(oldChar: ':', newChar: '_'); + } +} diff --git a/tracer/src/Datadog.Trace/Tags.cs b/tracer/src/Datadog.Trace/Tags.cs index 4a4e930f9ba2..a4205be8a446 100644 --- a/tracer/src/Datadog.Trace/Tags.cs +++ b/tracer/src/Datadog.Trace/Tags.cs @@ -506,6 +506,12 @@ public static partial class Tags /// internal const string RuntimeFamily = "_dd.runtime_family"; + /// + /// Contains a serialized list of process tags, that can be used in the backend for service renaming. + /// + /// + internal const string ProcessTags = "_dd.tags.process"; + /// /// The resource ID of the site instance in Azure App Services where the traced application is running. /// diff --git a/tracer/test/Datadog.Trace.IntegrationTests/Tagging/ProcessTagsTests.cs b/tracer/test/Datadog.Trace.IntegrationTests/Tagging/ProcessTagsTests.cs new file mode 100644 index 000000000000..1b2dfd5a2094 --- /dev/null +++ b/tracer/test/Datadog.Trace.IntegrationTests/Tagging/ProcessTagsTests.cs @@ -0,0 +1,66 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +using System.Collections.Specialized; +using System.Linq; +using System.Threading.Tasks; +using Datadog.Trace.Agent; +using Datadog.Trace.Configuration; +using Datadog.Trace.TestHelpers; +using Datadog.Trace.TestHelpers.TestTracer; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.IntegrationTests.Tagging; + +public class ProcessTagsTests +{ + private readonly MockApi _testApi; + + public ProcessTagsTests() + { + _testApi = new MockApi(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ProcessTags_Only_In_First_Span(bool enabled) + { + var settings = new TracerSettings(new NameValueConfigurationSource(new NameValueCollection { { ConfigurationKeys.PropagateProcessTags, enabled ? "true" : "false" } })); + var agentWriter = new AgentWriter(_testApi, statsAggregator: null, statsd: null, automaticFlush: false); + await using var tracer = TracerHelper.Create(settings, agentWriter); + + using (tracer.StartActiveInternal("A")) + using (tracer.StartActiveInternal("AA")) + { + } + + // other trace + using (tracer.StartActiveInternal("B")) + using (tracer.StartActiveInternal("BB")) + { + } + + await tracer.FlushAsync(); + var traceChunks = _testApi.Wait(); + + traceChunks.Should().HaveCount(2); // 2 (small) traces = 2 chunks + if (enabled) + { + // process tags written only to first span of first chunk + traceChunks[0][0].Tags.Should().ContainKey(Tags.ProcessTags); + } + else + { + traceChunks[0][0].Tags.Should().NotContainKey(Tags.ProcessTags); + } + + traceChunks.SelectMany(x => x) // flatten + .Skip(1) // exclude first item that we just checked above + .Should() + .AllSatisfy(s => s.Tags.Should().NotContainKey(Tags.ProcessTags)); + } +} diff --git a/tracer/test/Datadog.Trace.Tests/Agent/SpanBufferTests.cs b/tracer/test/Datadog.Trace.Tests/Agent/SpanBufferTests.cs index 53f388c586f5..723d868b7c22 100644 --- a/tracer/test/Datadog.Trace.Tests/Agent/SpanBufferTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Agent/SpanBufferTests.cs @@ -4,12 +4,15 @@ // using System; +using System.Collections.Generic; using System.Linq; using Datadog.Trace.Agent; using Datadog.Trace.Agent.MessagePack; using Datadog.Trace.TestHelpers; +using Datadog.Trace.Vendors.MessagePack.Formatters; using FluentAssertions; using MessagePack; // use nuget MessagePack to deserialize +using Moq; using Xunit; namespace Datadog.Trace.Tests.Agent @@ -126,6 +129,28 @@ public void TemporaryBufferSizeLimit() temporaryBuffer.Length.Should().BeLessThanOrEqualTo(512, because: "the size of the temporary buffer shouldn't exceed twice the limit"); } + [Fact] + public void IsFirstChunkInBuffer_FirstChunkIsTrue_SubsequentChunksAreFalse() + { + var interceptedChunks = new List(); + var interceptingFormatter = new InterceptingTraceChunkFormatter(interceptedChunks); + var mockResolver = new Mock(); + mockResolver.Setup(r => r.GetFormatter()).Returns(interceptingFormatter); + + var buffer = new SpanBuffer(maxBufferSize: 256, mockResolver.Object); + var temporaryBuffer = new byte[256]; + + var firstSpanArray = CreateTraceChunk(2); + var secondSpanArray = CreateTraceChunk(spanCount: 2, startingId: 10); + + buffer.TryWrite(firstSpanArray, ref temporaryBuffer).Should().Be(SpanBuffer.WriteStatus.Success); + buffer.TryWrite(secondSpanArray, ref temporaryBuffer).Should().Be(SpanBuffer.WriteStatus.Success); + + interceptedChunks.Should().HaveCount(2); + interceptedChunks[0].IsFirstChunkInPayload.Should().BeTrue(); + interceptedChunks[1].IsFirstChunkInPayload.Should().BeFalse(); + } + private static ArraySegment CreateTraceChunk(int spanCount, ulong startingId = 1) { var spans = new Span[spanCount]; @@ -138,5 +163,22 @@ private static ArraySegment CreateTraceChunk(int spanCount, ulong starting return new ArraySegment(spans); } + + /// + /// practical mock, because the presence of the ref modifier on bytes makes it not work well with Moq. + /// + private class InterceptingTraceChunkFormatter(List interceptedChunks) : IMessagePackFormatter + { + public int Serialize(ref byte[] bytes, int offset, TraceChunkModel value, Vendors.MessagePack.IFormatterResolver formatterResolver) + { + interceptedChunks.Add(value); + return 50; // Return a reasonable serialized size + } + + public TraceChunkModel Deserialize(byte[] bytes, int offset, Vendors.MessagePack.IFormatterResolver formatterResolver, out int readSize) + { + throw new NotImplementedException("Deserialization not needed for this test"); + } + } } } diff --git a/tracer/test/Datadog.Trace.Tests/Configuration/TracerSettingsTests.cs b/tracer/test/Datadog.Trace.Tests/Configuration/TracerSettingsTests.cs index 6923a11aa377..c02e9d3d052b 100644 --- a/tracer/test/Datadog.Trace.Tests/Configuration/TracerSettingsTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Configuration/TracerSettingsTests.cs @@ -1069,5 +1069,29 @@ public void OtlpLogsTimeoutMsFallback(string logsTimeout, string generalTimeout, settings.OtlpLogsTimeoutMs.Should().Be(expected); } + + [Theory] + [MemberData(nameof(BooleanTestCases), false)] + public void ProcessTagsEnabled(string value, bool expected) + { + var source = CreateConfigurationSource((ConfigurationKeys.PropagateProcessTags, value)); + var settings = new TracerSettings(source); + + settings.PropagateProcessTags.Should().Be(expected); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("none", false)] + [InlineData("all", true)] + [InlineData(ConfigurationKeys.PropagateProcessTags, true)] + public void ProcessTagsEnabledIfExperimentalEnabled(string value, bool expected) + { + var source = CreateConfigurationSource((ConfigurationKeys.ExperimentalFeaturesEnabled, value)); + var settings = new TracerSettings(source); + + settings.PropagateProcessTags.Should().Be(expected); + } } } diff --git a/tracer/test/Datadog.Trace.Tests/ProcessTagsTests.cs b/tracer/test/Datadog.Trace.Tests/ProcessTagsTests.cs new file mode 100644 index 000000000000..669825df92e1 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/ProcessTagsTests.cs @@ -0,0 +1,33 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +using System.Linq; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests; + +public class ProcessTagsTests +{ + [Fact] + public void TagsPresentWhenEnabled() + { + var tags = ProcessTags.SerializedTags; + + tags.Should().ContainAll(ProcessTags.EntrypointBasedir, ProcessTags.EntrypointWorkdir); + // EntrypointName may not be present, especially when ran in the CI + + tags.Split(',').Should().BeInAscendingOrder(); + + // assert on the format, which is key:values in a string, comma separated. + tags.Split(',') + .Should() + .AllSatisfy(s => + { + s.Count(c => c == ':').Should().Be(1); + }); + // cannot really assert on content because it depends on how the tests are run. + } +} diff --git a/tracer/test/Datadog.Trace.Tests/Telemetry/config_norm_rules.json b/tracer/test/Datadog.Trace.Tests/Telemetry/config_norm_rules.json index 53bb5c48ac60..fe0f460a0adb 100644 --- a/tracer/test/Datadog.Trace.Tests/Telemetry/config_norm_rules.json +++ b/tracer/test/Datadog.Trace.Tests/Telemetry/config_norm_rules.json @@ -701,5 +701,6 @@ "DD_TRACE_GRAPHQL_ERROR_EXTENSIONS": "trace_graphql_error_extensions", "DD_APPLICATION_MONITORING_CONFIG_FILE_ENABLED":"application_monitoring_config_file_enabled", "DD_TRACE_AZURE_SERVICEBUS_BATCH_LINKS_ENABLED": "trace_azure_servicebus_batch_links_enabled", - "DD_TRACE_AZURE_EVENTHUBS_BATCH_LINKS_ENABLED": "trace_azure_eventhubs_batch_links_enabled" + "DD_TRACE_AZURE_EVENTHUBS_BATCH_LINKS_ENABLED": "trace_azure_eventhubs_batch_links_enabled", + "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": "experimental_propagate_process_tags_enabled" }