Skip to content
249 changes: 249 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)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
[Verb("appname", isDefault: false, HelpText = "Show or decode the DAB telemetry 'Application Name' embedded in SQL connections.", Hidden = false)]
[Verb("appname", isDefault: false, HelpText = "Show or decode the DAB telemetry 'Application Name' embedded in datasource 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this tested to work with both absolute and relative paths? How does this handle an incorrect file name?

}
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