Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
543c9b1
feat(feature-flags): add agentless configuration keys to supported-co…
pavlokhrebto Aug 13, 2026
4753079
feat(feature-flags): add FeatureFlagsSettings with source resolution …
pavlokhrebto Aug 13, 2026
25e168f
feat(feature-flags): add AgentlessEndpoint for CDN URL derivation
pavlokhrebto Aug 13, 2026
9c3a236
test(feature-flags): add unit tests for FeatureFlagsSettings and Agen…
pavlokhrebto Aug 13, 2026
95bf50b
fix(tests): correct AgentlessEndpointTests invalid URL expectations
pavlokhrebto Aug 13, 2026
4e6232b
fix(feature-flags): redact agentless base URL telemetry and reject ma…
pavlokhrebto Aug 14, 2026
7ee3cf3
fix: remove unnecessary null-forgiving operators in AgentlessEndpoint
pavlokhrebto Aug 17, 2026
02a6ac6
fix: drop redundant HasWhitespace check, defer to Uri.TryCreate per r…
pavlokhrebto Aug 17, 2026
7f72e7f
fix: reword credential comment and remove unnecessary null-forgiving …
pavlokhrebto Aug 17, 2026
fb32e7d
refactor(feature-flags): use config framework for source resolution a…
pavlokhrebto Aug 17, 2026
2911d78
fix: mark DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL as…
pavlokhrebto Aug 17, 2026
7af7bc5
refactor: pass site/env/apiKey via constructor instead of re-reading …
pavlokhrebto Aug 17, 2026
8f406d9
[FeatureFlags] Resolve the configuration source in a single telemetry…
pavlokhrebto Aug 18, 2026
ada921d
[FeatureFlags] Make AgentlessEndpoint a class so a failed TryCreate y…
pavlokhrebto Aug 18, 2026
8c4f3b3
Merge branch 'master' of github.com:DataDog/dd-trace-dotnet into pavl…
pavlokhrebto Aug 20, 2026
154ef58
[FeatureFlags] Apply dd_env per request instead of baking it into the…
pavlokhrebto Aug 20, 2026
dbae9dc
[FeatureFlags] Normalize dd_env the same way span tags are normalized
pavlokhrebto Aug 20, 2026
7c7d7e6
[FeatureFlags] Report an unrecognised configuration source as a parsi…
pavlokhrebto Aug 20, 2026
d6aafaf
Merge branch 'master' of github.com:DataDog/dd-trace-dotnet into pavl…
pavlokhrebto Aug 25, 2026
2317ea8
[FeatureFlags] Fix double question mark in agentless request URI on .…
pavlokhrebto Aug 25, 2026
d868f5a
[FeatureFlags] Align the provider initialization timeout default with…
pavlokhrebto Aug 26, 2026
b2f012a
Fix comments
pavlokhrebto Aug 26, 2026
97fdde3
[FeatureFlags] Add dd_env to the managed agentless endpoint only
pavlokhrebto Aug 26, 2026
eba74ae
[FeatureFlags] Name the offline source after the value that selects it
pavlokhrebto Aug 26, 2026
1c063c3
[FeatureFlags] Address review comments on source resolution and endpo…
pavlokhrebto Aug 26, 2026
85eacdb
FeatureFlags] Document the agentless base URL contract
pavlokhrebto Aug 26, 2026
4149d7b
[FeatureFlags] Fail closed on an unrecognised configuration source
pavlokhrebto Aug 26, 2026
3bfd025
[FeatureFlags] Reject a Datadog site that can redirect the managed en…
pavlokhrebto Aug 26, 2026
d8e1083
[FeatureFlags] Reject a Datadog site that can redirect the managed en…
pavlokhrebto Aug 26, 2026
aa8fc5b
[FeatureFlags] Separate whether Feature Flags run from where configur…
pavlokhrebto Aug 27, 2026
b3f5318
Merge branch 'master' of github.com:DataDog/dd-trace-dotnet into pavl…
pavlokhrebto Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions tracer/src/Datadog.Trace/Configuration/TracerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using Datadog.Trace.Configuration.ConfigurationSources.Telemetry;
using Datadog.Trace.Configuration.Telemetry;
using Datadog.Trace.DataStreamsMonitoring.TransactionTracking;
using Datadog.Trace.FeatureFlags;
using Datadog.Trace.Logging;
using Datadog.Trace.Logging.DirectSubmission;
using Datadog.Trace.PlatformHelpers;
Expand Down Expand Up @@ -888,6 +889,11 @@ not null when string.Equals(value, "otlp", StringComparison.OrdinalIgnoreCase) =

Manager = new(source, this, telemetry, errorLog);

// The environment is deliberately not passed in: it can be changed in code after
// startup, so the delivery source subscribes to the manager and applies the current
// value per request instead of capturing one here.
FeatureFlags = new FeatureFlagsSettings(source, telemetry);

// OTLP span metrics require OTLP trace export (see TracerManagerFactory.GetAgentWriter).
// Force to false otherwise, even if explicitly requested.
if (OtelTracesSpanMetricsEnabled && !Manager.InitialExporterSettings.IsOtlpTraceExport)
Expand Down Expand Up @@ -1500,6 +1506,11 @@ not null when string.Equals(value, "otlp", StringComparison.OrdinalIgnoreCase) =
/// </summary>
internal bool IsSpanEnrichmentEnabled { get; }

/// <summary>
/// Gets the Feature Flags settings, which select where flag configuration is delivered from.
/// </summary>
internal FeatureFlagsSettings FeatureFlags { get; }

/// <summary>
/// Gets a value indicating whether partial flush is enabled
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3157,6 +3157,85 @@ supportedConfigurations:
documentation: |-
Enables Feature Flags Provider (Experimental).
Default value is <c>false</c> (disabled).
DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS:
- implementation: A
scope: managed
type: int
default: '30000'
product: FeatureFlags
const_name: FlaggingProviderInitializationTimeoutMs
documentation: |-
Configuration key for how long, in milliseconds, provider initialization waits for the first
flag configuration to arrive before returning.
Default value is <c>30000</c> (30 seconds), matching the other tracers.
Initialization does not fail when the timeout expires: the provider stays not-ready, evaluations
return the caller's default value, and the provider becomes ready when configuration arrives.
DD_FEATURE_FLAGS_ENABLED:
- implementation: A
scope: managed
type: boolean
default: 'true'
product: FeatureFlags
const_name: FeatureFlagsEnabled
documentation: |-
Configuration key to enable or disable Feature Flags.
Default value is <c>true</c> (enabled).
Feature Flags only contact Datadog once application code initializes the provider, so enabling
this alone does not start requesting flag configuration.
This supersedes <see cref="ConfigurationKeys.FeatureFlags.FlaggingProviderEnabled"/>.
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE:
- implementation: A
scope: managed
type: string
default: agentless
product: FeatureFlags
const_name: FeatureFlagsConfigurationSource
documentation: |-
Configuration key for selecting where flag configuration is loaded from.
Supported values are <c>agentless</c> (direct HTTP delivery, the default),
<c>remote_config</c> (delivery through the Datadog Agent's Remote Configuration)
and <c>offline</c>, which disables Feature Flags and contacts nothing.
Any other value is rejected and the default applies.
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL:
- implementation: A
scope: managed
type: string
sensitive: true
default: null
product: FeatureFlags
const_name: FeatureFlagsConfigurationSourceAgentlessBaseUrl
documentation: |-
Configuration key for overriding the endpoint used by the <c>agentless</c> configuration source.
If unset, the endpoint is derived from <see cref="ConfigurationKeys.Site"/> and Datadog hosts it.
A configured URL is treated as an endpoint of your own, which changes three things:
the Datadog API key is not sent to it, so it is responsible for its own authentication;
it is requested exactly as written, so <c>dd_env</c> is not added and any environment or tenant
scope has to be part of the URL you configure;
and only its path is completed, when it has none or a path of <c>/</c>, with the standard
rules-based server path. Any other path is used verbatim as the exact endpoint.
The value may carry credentials, so it is never written to logs or telemetry.
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS:
- implementation: A
scope: managed
type: int
default: '30'
product: FeatureFlags
const_name: FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds
documentation: |-
Configuration key for how often, in seconds, the <c>agentless</c> configuration source polls for
flag configuration.
Default value is <c>30</c>. Values outside <c>(0, 3600]</c> are rejected and the default is used.
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS:
- implementation: A
scope: managed
type: int
default: '5'
product: FeatureFlags
const_name: FeatureFlagsConfigurationSourceAgentlessRequestTimeoutSeconds
documentation: |-
Configuration key for the request timeout, in seconds, used by the <c>agentless</c> configuration
source.
Default value is <c>5</c>. Non-positive values are rejected and the default is used.
DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED:
- implementation: A
scope: managed
Expand Down
186 changes: 186 additions & 0 deletions tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessEndpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// <copyright file="AgentlessEndpoint.cs" company="Datadog">
// 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.
// </copyright>

#nullable enable

using System;
using System.Diagnostics.CodeAnalysis;
using Datadog.Trace.Processors;
using Datadog.Trace.Util;

namespace Datadog.Trace.FeatureFlags.Agentless;

/// <summary>
/// The agentless endpoint, derived from the Datadog site or a custom base URL. A class rather
/// than a struct so that "no endpoint" is <c>null</c> instead of a default instance whose
/// non-nullable <see cref="Uri"/> is null; it is built once per process, so the allocation is free.
/// </summary>
internal sealed class AgentlessEndpoint
{
/// <summary>
/// Canonical rules-based server path, appended to the managed CDN host and to custom base
/// URLs that only supply an origin.
/// </summary>
internal const string DefaultPath = "/api/v2/feature-flagging/config/rules-based/server";

/// <summary>
/// The prefix prepended to the site to form the managed CDN host.
/// </summary>
internal const string ManagedHostPrefix = "ufc-server.ff-cdn.";

/// <summary>
/// The query parameter carrying the environment to request configuration for. Added to the
/// managed endpoint only.
/// </summary>
internal const string EnvParameterName = "dd_env";

private AgentlessEndpoint(Uri uri, bool isManaged)
{
Uri = uri;
IsManaged = isManaged;
Comment thread
pavlokhrebto marked this conversation as resolved.
}

/// <summary>
/// Gets the endpoint URI, without the environment. Use <see cref="BuildRequestUri"/> to get the
/// URI to request.
/// </summary>
public Uri Uri { get; }

/// <summary>
/// Gets a value indicating whether this is the endpoint derived from the site. The API key is
/// only sent there: a custom endpoint reports its own authentication failure rather than
/// having the credential leaked to it.
/// </summary>
public bool IsManaged { get; }
Comment thread
pavlokhrebto marked this conversation as resolved.

/// <summary>
/// Builds the endpoint. Without a custom <paramref name="baseUrl"/> the managed Datadog CDN
/// endpoint is derived from the (lowercased) site, so staging and government sites resolve
/// with no allowlist, and is the only endpoint <c>dd_env</c> is added to. A custom base URL that
/// is an origin receives the canonical path; one that carries a path is used verbatim.
/// <para>
/// The environment is not part of the endpoint: it can be changed in code while the application
/// runs, so it is applied per request by <see cref="BuildRequestUri"/> instead.
/// </para>
/// </summary>
/// <param name="site">The Datadog site, for example <c>datadoghq.com</c>.</param>
/// <param name="baseUrl">The configured endpoint override, or <c>null</c>.</param>
/// <param name="endpoint">The resulting endpoint, or <c>null</c> when none could be built.</param>
/// <param name="error">Why the configured base URL was rejected. Never contains the URL, which may carry credentials.</param>
/// <returns><c>true</c> when an endpoint could be built.</returns>
public static bool TryCreate(string? site, string? baseUrl, [NotNullWhen(true)] out AgentlessEndpoint? endpoint, out string? error)
{
endpoint = null;
error = null;

var configured = baseUrl?.Trim();
if (StringUtil.IsNullOrEmpty(configured))
{
var trimmedSite = site?.Trim();
if (StringUtil.IsNullOrEmpty(trimmedSite))
{
error = "No Datadog site is configured";
return false;
}

// The site is concatenated into a host, so every character that can change what a URL means
// has to be rejected before that happens. "@" is the dangerous one: it would make the rest of
// the value the real host, and the API key would be sent there. "/", "?" and "#" would start a
// path, query or fragment, and ":" a port or a scheme. Uri.TryCreate accepts several of these,
// so it cannot be relied on to catch them. The other tracers reject the same set.
foreach (var character in trimmedSite)
{
if (char.IsWhiteSpace(character) || character is '/' or '?' or '#' or '@' or ':')
{
error = "The configured Datadog site is not valid";
return false;
}
}

var managedHost = ManagedHostPrefix + trimmedSite.ToLowerInvariant();

if (!Uri.TryCreate($"https://{managedHost}{DefaultPath}", UriKind.Absolute, out var managedUri))
{
error = "The configured Datadog site is not valid";
return false;
}

endpoint = new AgentlessEndpoint(managedUri, isManaged: true);
return true;
}

// Uri parsing rejects whitespace inside a host, but accepts it in a path or query, so a
// URL carrying it there is malformed and has to be caught explicitly.
foreach (var character in configured)
{
if (char.IsWhiteSpace(character))
{
error = "The configured Feature Flags agentless URL is not a valid URL";
return false;
}
}

if (!Uri.TryCreate(configured, UriKind.Absolute, out var custom) || StringUtil.IsNullOrEmpty(custom.Host))
{
error = "The configured Feature Flags agentless URL is not a valid absolute URL";
return false;
}

// http is accepted for a custom endpoint only: pointing at one is an operator decision.
if (custom.Scheme != Uri.UriSchemeHttps && custom.Scheme != Uri.UriSchemeHttp)
{
error = "The configured Feature Flags agentless URL must use HTTP or HTTPS";
return false;
}

if (custom.AbsolutePath is "" or "/")
{
custom = new UriBuilder(custom) { Path = DefaultPath }.Uri;
}
Comment thread
pavlokhrebto marked this conversation as resolved.

endpoint = new AgentlessEndpoint(custom, isManaged: false);
return true;
}

/// <summary>
/// Returns the URI to request configuration for <paramref name="env"/>. The environment is
/// added as a query parameter rather than baked into the endpoint, because it can change while
/// the application runs.
/// <para>
/// A configured base URL is opaque: it is requested exactly as the operator wrote it, since it
/// may hold credentials, routing, or a scope of its own. Only the managed endpoint carries
/// <c>dd_env</c>, which matches the other tracers.
/// </para>
/// </summary>
/// <param name="env">The current environment, or <c>null</c> when none is configured.</param>
/// <returns>The URI to request.</returns>
public Uri BuildRequestUri(string? env)
{
if (!IsManaged)
{
return Uri;
}

// Normalized the same way the tracer normalizes it before tagging spans, so that flag
// targeting and span tags agree on what the environment is. It also bounds the value at 200
// characters, which keeps a misconfigured environment from producing an unusable URL.
// A value that normalizes to nothing is treated as no environment at all.
var normalized = TraceUtil.NormalizeTag(env);
if (StringUtil.IsNullOrEmpty(normalized))
{
return Uri;
}

var parameter = EnvParameterName + "=" + Uri.EscapeDataString(normalized);
var builder = new UriBuilder(Uri);

// The getter returns the query with its leading "?", while the setter prepends one of its
// own on .NET Framework, so the existing query is trimmed before it is extended. A URL
// ending in a bare "?" reports a query of "?", which the length check treats as no query.
var existing = builder.Query;
builder.Query = existing.Length > 1 ? existing.TrimStart('?') + "&" + parameter : parameter;
return builder.Uri;
}
}
Loading
Loading