Skip to content
241 changes: 241 additions & 0 deletions docs/design/application-name-telemetry.md

Large diffs are not rendered by default.

133 changes: 132 additions & 1 deletion src/Cli.Tests/EndToEndTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using Azure.DataApiBuilder.Config.Converters;
using Azure.DataApiBuilder.Config.Telemetry;
using Azure.DataApiBuilder.Product;
using Azure.DataApiBuilder.Service;
using Cli.Constants;
Expand Down Expand Up @@ -128,7 +129,10 @@ public void TestInitializingRestAndGraphQLGlobalSettings()
replacementSettings: replacementSettings));

SqlConnectionStringBuilder builder = new(runtimeConfig.DataSource!.ConnectionString);
Assert.AreEqual(ProductInfo.GetDataApiBuilderUserAgent(), builder.ApplicationName);
// Application Name now embeds the dab_oss telemetry block (dab_oss_<version>+<payload>+),
// so assert it begins with the product user agent rather than exact-matching it.
Assert.IsTrue(builder.ApplicationName.StartsWith(ProductInfo.GetDataApiBuilderUserAgent()),
$"Expected Application Name to start with '{ProductInfo.GetDataApiBuilderUserAgent()}' but was '{builder.ApplicationName}'.");

Assert.IsNotNull(runtimeConfig);
Assert.AreEqual(DatabaseType.MSSQL, runtimeConfig.DataSource.DatabaseType);
Expand All @@ -139,6 +143,133 @@ public void TestInitializingRestAndGraphQLGlobalSettings()
Assert.IsTrue(runtimeConfig.Runtime.GraphQL?.Enabled);
}

/// <summary>
/// The `appname` command encodes the telemetry Application Name from a config (offline — no
/// validation and no database connection) and decodes a telemetry string back into a
/// human-readable description.
/// </summary>
[TestMethod]
public void TestAppNameEncodeAndDecode()
{
// Arrange: a minimal, self-contained MSSQL config in the mock file system.
string configJson = @"{
""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"",
""data-source"": { ""database-type"": ""mssql"", ""connection-string"": ""Server=localhost;Database=demo;User Id=sa;Password=Placeholder1;"" },
""runtime"": { ""rest"": { ""enabled"": true }, ""graphql"": { ""enabled"": true }, ""host"": { ""mode"": ""development"", ""authentication"": { ""provider"": ""StaticWebApps"" } } },
""entities"": { ""Book"": { ""source"": { ""object"": ""dbo.books"", ""type"": ""table"" }, ""permissions"": [ { ""role"": ""anonymous"", ""actions"": [ ""read"" ] } ] } }
}";
_fileSystem!.File.WriteAllText("appname-config.json", configJson);

// Act: encode to an output file. This must succeed offline (no validation / no DB connection).
int encodeCode = Program.Execute(
new[] { "appname", "--config", "appname-config.json", "--output", "appname-out.txt" },
_cliLogger!, _fileSystem!, _runtimeConfigLoader!);

// Assert: encode succeeded and produced a well-formed telemetry string.
Assert.AreEqual(0, encodeCode, "appname --config should succeed offline");
string telemetry = _fileSystem.File.ReadAllText("appname-out.txt");
Assert.IsTrue(telemetry.StartsWith("dab_oss_"), telemetry);
Assert.IsTrue(telemetry.EndsWith("+"), telemetry);

// Act: decode the produced string back into a human-readable description.
int decodeCode = Program.Execute(
new[] { "appname", "--decode", telemetry, "--output", "appname-decoded.txt" },
_cliLogger!, _fileSystem!, _runtimeConfigLoader!);

// Assert: decode succeeded and produced recognizable lines.
Assert.AreEqual(0, decodeCode, "appname --decode should succeed");
string decoded = _fileSystem.File.ReadAllText("appname-decoded.txt");
Assert.IsTrue(decoded.Contains("Version: dab_oss_"), decoded);
Assert.IsTrue(decoded.Contains("runtime.rest.enabled"), decoded);
Assert.IsTrue(decoded.Contains("entities.any.table"), decoded);
}

/// <summary>
/// The `appname` encode path returns GENERAL_ERROR (and writes no output file) when the config
/// file cannot be found.
/// </summary>
[TestMethod]
public void TestAppNameEncodeFailsWhenConfigMissing()
{
int code = Program.Execute(
new[] { "appname", "--config", "does-not-exist.json", "--output", "appname-out.txt" },
_cliLogger!, _fileSystem!, _runtimeConfigLoader!);

Assert.AreEqual(CliReturnCode.GENERAL_ERROR, code, "appname encode should fail when the config cannot be found.");
Assert.IsFalse(_fileSystem!.File.Exists("appname-out.txt"), "No output file should be written on failure.");
}

/// <summary>
/// The `appname` encode path writes the telemetry string to stdout when --output is omitted.
/// </summary>
[TestMethod]
public void TestAppNameEncodeWritesToStdout()
{
string configJson = @"{
""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"",
""data-source"": { ""database-type"": ""mssql"", ""connection-string"": ""Server=localhost;Database=demo;User Id=sa;Password=Placeholder1;"" },
""runtime"": { ""rest"": { ""enabled"": true }, ""graphql"": { ""enabled"": true } },
""entities"": { }
}";
_fileSystem!.File.WriteAllText("appname-config.json", configJson);

TextWriter originalOut = Console.Out;
StringWriter capturedOut = new();
Console.SetOut(capturedOut);
int code;
try
{
code = Program.Execute(
new[] { "appname", "--config", "appname-config.json" },
_cliLogger!, _fileSystem!, _runtimeConfigLoader!);
}
finally
{
Console.SetOut(originalOut);
}

Assert.AreEqual(CliReturnCode.SUCCESS, code, "appname encode to stdout should succeed.");
string stdout = capturedOut.ToString();
Assert.IsTrue(stdout.Contains("dab_oss_"), $"stdout should contain the telemetry marker but was '{stdout}'.");
Assert.IsTrue(stdout.TrimEnd().EndsWith("+"), $"stdout telemetry should end with '+' but was '{stdout}'.");
}

/// <summary>
/// The `appname` command is a design-time inspection tool: it always shows the full telemetry
/// encoding so users can see what would be collected, independent of the runtime opt-out switch
/// (DAB_TELEMETRY_APPNAME_OPT_OUT). This pins that intentional behavior.
/// </summary>
[TestMethod]
public void TestAppNameEncodeIsIndependentOfOptOut()
{
string? originalOptOut = Environment.GetEnvironmentVariable(ApplicationNameTelemetry.OPT_OUT_ENV_VAR);
Environment.SetEnvironmentVariable(ApplicationNameTelemetry.OPT_OUT_ENV_VAR, "1");
try
{
string configJson = @"{
""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"",
""data-source"": { ""database-type"": ""mssql"", ""connection-string"": ""Server=localhost;Database=demo;User Id=sa;Password=Placeholder1;"" },
""runtime"": { ""rest"": { ""enabled"": true }, ""graphql"": { ""enabled"": true } },
""entities"": { }
}";
_fileSystem!.File.WriteAllText("appname-config.json", configJson);

int code = Program.Execute(
new[] { "appname", "--config", "appname-config.json", "--output", "appname-out.txt" },
_cliLogger!, _fileSystem!, _runtimeConfigLoader!);

Assert.AreEqual(CliReturnCode.SUCCESS, code, "appname encode should succeed even when opted out.");
string telemetry = _fileSystem.File.ReadAllText("appname-out.txt");
Assert.IsTrue(
telemetry.StartsWith("dab_oss_") && telemetry.EndsWith("+"),
$"appname should show the full telemetry encoding regardless of opt-out, but was '{telemetry}'.");
}
finally
{
Environment.SetEnvironmentVariable(ApplicationNameTelemetry.OPT_OUT_ENV_VAR, originalOptOut);
}
}

/// <summary>
/// Test to validate the usage of --graphql.multiple-mutations.create.enabled option of the init command for all database types.
///
Expand Down
104 changes: 104 additions & 0 deletions src/Cli/Commands/AppNameOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.IO.Abstractions;
using Azure.DataApiBuilder.Config;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Config.Telemetry;
using Azure.DataApiBuilder.Core.Configurations;
using Cli.Constants;
using CommandLine;
using Microsoft.Extensions.Logging;

namespace Cli.Commands
{
/// <summary>
/// Options for the <c>appname</c> command, which encodes the DAB telemetry Application Name
/// from a config file, or decodes a telemetry Application Name into a human-readable description.
/// </summary>
[Verb("appname", isDefault: false, HelpText = "Show or decode the DAB telemetry 'Application Name' embedded in SQL connections.", Hidden = false)]
public class AppNameOptions : Options
{
public AppNameOptions(string? decode = null, string? output = null, string? config = null)
: base(config)
{
Decode = decode;
Output = output;
}

/// <summary>
/// When provided, decodes the given telemetry Application Name string into a human-readable
/// description instead of encoding from a config file. Decoding is tolerant of truncation.
/// </summary>
[Option("decode", Required = false, HelpText = "Decode a telemetry Application Name string into a human-readable description.")]
public string? Decode { get; }

/// <summary>
/// Optional file path to write the result to. When omitted, the result is written to stdout.
/// </summary>
[Option('o', "output", Required = false, HelpText = "Write the result to the specified file instead of stdout.")]
public string? Output { get; }

/// <summary>
/// Handles the <c>appname</c> command.
/// </summary>
public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
{
// Decode mode: a pure, tolerant string decode. No config or validation is required.
// Presence of the option (even with an empty/whitespace value) selects decode mode; the
// decoder itself reports a friendly message for empty input.
if (Decode is not null)
{
IReadOnlyList<string> decodedLines = ApplicationNameTelemetry.Decode(Decode);
WriteResult(string.Join(Environment.NewLine, decodedLines), fileSystem, logger, trailingNewLine: true);
return CliReturnCode.SUCCESS;
}

// Encode mode: parse the config and emit the telemetry Application Name.
// We intentionally do NOT run full `validate` here — validation opens a database
// connection, whereas encoding only needs the parsed runtime/entity settings.
// Requiring a live database would defeat the purpose of this static inspection command.
if (!ConfigGenerator.TryGetConfigForRuntimeEngine(Config, loader, fileSystem, out _))
{
logger.LogError("Could not determine the config file to use.");
return CliReturnCode.GENERAL_ERROR;
}

RuntimeConfigProvider runtimeConfigProvider = new(loader);
if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig) || runtimeConfig is null)
{
logger.LogError("Failed to parse the config file.");
return CliReturnCode.GENERAL_ERROR;
}

// There is no live connection context at design time, so the context fields
// (Protocol/Object/Source/Role) are emitted as placeholders.
string telemetryAppName = ApplicationNameTelemetry.EncodeTelemetryString(runtimeConfig, liveDataSource: null);
WriteResult(telemetryAppName, fileSystem, logger, trailingNewLine: false);
return CliReturnCode.SUCCESS;
}

/// <summary>
/// Writes the result to the output file when <c>--output</c> is provided, otherwise to stdout.
/// </summary>
private void WriteResult(string content, IFileSystem fileSystem, ILogger logger, bool trailingNewLine)
{
if (!string.IsNullOrWhiteSpace(Output))
{
// Mirror stdout behavior: append a trailing newline for human-readable (decode) output,
// but keep encode output exact (no trailing newline) so it can be copied/piped verbatim.
string fileContent = trailingNewLine ? content + Environment.NewLine : content;
fileSystem.File.WriteAllText(Output, fileContent);
logger.LogInformation("Wrote output to '{outputFile}'.", Output);
}
else if (trailingNewLine)
{
Console.WriteLine(content);
}
else
{
Console.Write(content);
}
}
}
}
3 changes: 2 additions & 1 deletion src/Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public static int Execute(string[] args, ILogger cliLogger, IFileSystem fileSyst
});

// Parsing user arguments and executing required methods.
int result = parser.ParseArguments<InitOptions, AddOptions, UpdateOptions, StartOptions, ValidateOptions, ExportOptions, AddTelemetryOptions, ConfigureOptions, AutoConfigOptions, AutoConfigSimulateOptions>(args)
int result = parser.ParseArguments<InitOptions, AddOptions, UpdateOptions, StartOptions, ValidateOptions, ExportOptions, AddTelemetryOptions, ConfigureOptions, AutoConfigOptions, AutoConfigSimulateOptions, AppNameOptions>(args)
.MapResult(
(InitOptions options) => options.Handler(cliLogger, loader, fileSystem),
(AddOptions options) => options.Handler(cliLogger, loader, fileSystem),
Expand All @@ -100,6 +100,7 @@ public static int Execute(string[] args, ILogger cliLogger, IFileSystem fileSyst
(AutoConfigOptions options) => options.Handler(cliLogger, loader, fileSystem),
(AutoConfigSimulateOptions options) => options.Handler(cliLogger, loader, fileSystem),
(ExportOptions options) => options.Handler(cliLogger, loader, fileSystem),
(AppNameOptions options) => options.Handler(cliLogger, loader, fileSystem),
errors => DabCliParserErrorHandler.ProcessErrorsAndReturnExitCode(errors));

return result;
Expand Down
8 changes: 8 additions & 0 deletions src/Config/DeserializationVariableReplacementSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public class DeserializationVariableReplacementSettings
public bool DoReplaceAkvVar { get; set; }
public EnvironmentVariableReplacementFailureMode EnvFailureMode { get; set; } = EnvironmentVariableReplacementFailureMode.Throw;

/// <summary>
/// When true, connection-string Application Name (telemetry) injection is skipped during this
/// parse. This is set for nested child config loads in a multi-database setup: a child config
/// lacks the global runtime section and knows only its own entities, so the top-level load
/// performs the injection once over the fully-merged config for every data source.
/// </summary>
public bool SkipApplicationNameInjection { get; set; }

// @env\(' : match @env('
// @akv\(' : match @akv('
// .*? : lazy match any character except newline 0 or more times
Expand Down
14 changes: 8 additions & 6 deletions src/Config/FileSystemRuntimeConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,11 @@ private void HotReloadConfig(bool isDevMode, ILogger? logger = null)
IsNewConfigValidated = false;
SignalConfigChanged();

// Telemetry (and any other) logs buffered during the reload parse are otherwise only
// drained once at startup. Flush them now so hot-reload logs are actually emitted and the
// shared static buffer does not accumulate entries across successive reloads.
FlushLogBuffer();

logger?.LogInformation("Hot-reload process finished.");
}

Expand Down Expand Up @@ -551,13 +556,10 @@ public void SetLogger(ILogger<FileSystemRuntimeConfigLoader> logger)
}

/// <summary>
/// Flush all logs from the buffer after the log level is set from the RuntimeConfig.
/// Logger needs to be present, or else the logs will be lost.
/// The logger this loader emits to once set, consumed by the base
/// <see cref="RuntimeConfigLoader.FlushLogBuffer"/> to drain buffered logs.
/// </summary>
public void FlushLogBuffer()
{
_logBuffer.FlushToLogger(_logger!);
}
protected override ILogger? Logger => _logger;

/// <summary>
/// Helper method that sends the log to the buffer if the logger has not being set up.
Expand Down
12 changes: 12 additions & 0 deletions src/Config/LogBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ namespace Azure.DataApiBuilder.Config
/// </summary>
public class LogBuffer
{
/// <summary>
/// Upper bound on buffered entries. Prevents unbounded growth when the buffer is never drained
/// (e.g. a loader with no logger in a hot-reload loop). The oldest entries are dropped first.
/// </summary>
internal const int MAX_BUFFERED_ENTRIES = 1000;

private readonly ConcurrentQueue<(LogLevel LogLevel, string Message, Exception? Exception)> _logBuffer;
private readonly object _flushLock = new();

Expand All @@ -26,6 +32,12 @@ public LogBuffer()
public void BufferLog(LogLevel logLevel, string message, Exception? exception = null)
{
_logBuffer.Enqueue((logLevel, message, exception));

// Keep the buffer bounded so it cannot grow without limit if it is never drained. Dropping
// the oldest entries first preserves the most recent (most useful) diagnostics.
while (_logBuffer.Count > MAX_BUFFERED_ENTRIES && _logBuffer.TryDequeue(out _))
{
}
Comment thread
aaronburtle marked this conversation as resolved.
}

/// <summary>
Expand Down
9 changes: 8 additions & 1 deletion src/Config/ObjectModel/RuntimeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,14 @@ public RuntimeConfig(
// be resolved using the parent's Key Vault configuration.
// If a child config defines its own azure-key-vault section, TryParseConfig's
// ExtractAzureKeyVaultOptions will detect it and override these parent options.
DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: this.AzureKeyVault, doReplaceEnvVar: true, doReplaceAkvVar: true, envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore);
DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: this.AzureKeyVault, doReplaceEnvVar: true, doReplaceAkvVar: true, envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore)
{
// Defer Application Name (telemetry) injection to the top-level load. A child config
// has no global runtime section and only its own entities; the root performs the
// injection once over the fully-merged config so each data source's pool reflects the
// global runtime and the complete entity set.
SkipApplicationNameInjection = true
};

foreach (string dataSourceFile in DataSourceFiles.SourceFiles)
{
Expand Down
Loading
Loading