diff --git a/.editorconfig b/.editorconfig index 2136cb7cc..e2f0c4ea8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,6 +7,8 @@ root = true [*.cs] dotnet_analyzer_diagnostic.severity = error +# Namespace does not match folder structure - Ideally this should be enabled but it seems to have issues with root level files so disabling for now +dotnet_diagnostic.IDE0130.severity = none # Documentation related errors, remove once they are fixed dotnet_diagnostic.CS1591.severity = none diff --git a/README.md b/README.md index 5e56d275e..53599c456 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Further information on the Azure SQL binding for Azure Functions is also availab - [Single Row](#single-row) - [Primary Keys and Identity Columns](#primary-keys-and-identity-columns) - [Known Issues](#known-issues) + - [Telemetry](#telemetry) - [Trademarks](#trademarks) ## Quick Start @@ -526,6 +527,10 @@ This changes if one of the primary key columns is an identity column though. In - Output bindings against tables with columns of data types `NTEXT`, `TEXT`, or `IMAGE` are not supported and data upserts will fail. These types [will be removed](https://docs.microsoft.com/sql/t-sql/data-types/ntext-text-and-image-transact-sql) in a future version of SQL Server and are not compatible with the `OPENJSON` function used by this Azure Functions binding. - Case-sensitive [collations](https://docs.microsoft.com/sql/relational-databases/collations/collation-and-unicode-support#Collation_Defn) are not currently supported. This functionality will be added in a future release. [#133](https://github.com/Azure/azure-functions-sql-extension/issues/133) tracks progress on this issue. +## Telemetry + +This extension collect usage data in order to help us improve your experience. The data is anonymous and doesn't include any personal information. You can opt-out of telemetry by setting the `AZUREFUNCTIONS_SQLBINDINGS_TELEMETRY_OPTOUT` environment variable or the `AzureFunctionsSqlBindingsTelemetryOptOut` app setting (in your `*.settings.json` file) to '1', 'true' or 'yes'; + ## Trademarks This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft’s Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party’s policies. \ No newline at end of file diff --git a/builds/azure-pipelines/template-steps-build-test.yml b/builds/azure-pipelines/template-steps-build-test.yml index 1c2227da7..87d6b0af6 100644 --- a/builds/azure-pipelines/template-steps-build-test.yml +++ b/builds/azure-pipelines/template-steps-build-test.yml @@ -191,6 +191,7 @@ steps: env: TEST_SERVER: '$(testServer)' NODE_MODULES_PATH: '$(nodeModulesPath)' + AZUREFUNCTIONS_SQLBINDINGS_TELEMETRY_OPTOUT: '1' inputs: command: test projects: '${{ parameters.solution }}' @@ -201,6 +202,7 @@ steps: displayName: '.NET Test on Linux' env: SA_PASSWORD: '$(serverPassword)' + AZUREFUNCTIONS_SQLBINDINGS_TELEMETRY_OPTOUT: '1' inputs: command: test projects: '${{ parameters.solution }}' diff --git a/samples/local.settings.json b/samples/local.settings.json index 068a09e74..e3a7a2e09 100644 --- a/samples/local.settings.json +++ b/samples/local.settings.json @@ -4,5 +4,5 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "dotnet", "SqlConnectionString": "" -} + } } \ No newline at end of file diff --git a/src/Microsoft.Azure.WebJobs.Extensions.Sql.csproj b/src/Microsoft.Azure.WebJobs.Extensions.Sql.csproj index 25a73dedd..2b88a3617 100644 --- a/src/Microsoft.Azure.WebJobs.Extensions.Sql.csproj +++ b/src/Microsoft.Azure.WebJobs.Extensions.Sql.csproj @@ -18,13 +18,12 @@ MIT https://github.com/Azure/azure-functions-sql-extension pkgicon.png - - true true + diff --git a/src/SqlBindingConfigProvider.cs b/src/SqlBindingConfigProvider.cs index e69313e15..f42100aef 100644 --- a/src/SqlBindingConfigProvider.cs +++ b/src/SqlBindingConfigProvider.cs @@ -45,6 +45,7 @@ public void Initialize(ExtensionConfigContext context) { throw new ArgumentNullException(nameof(context)); } + Telemetry.Telemetry.Instance.Initialize(this._configuration, this._loggerFactory); #pragma warning disable CS0618 // Fine to use this for our stuff FluentBindingRule inputOutputRule = context.AddBindingRule(); var converter = new SqlConverter(this._configuration); diff --git a/src/Telemetry/Telemetry.cs b/src/Telemetry/Telemetry.cs new file mode 100644 index 000000000..c23d7d704 --- /dev/null +++ b/src/Telemetry/Telemetry.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.Extensions.Configuration; +using Microsoft.Azure.WebJobs.Logging; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql.Telemetry +{ + public sealed class Telemetry + { + internal static Telemetry Instance = new Telemetry(); + + private const string EventsNamespace = "azure-functions-sql-bindings"; + internal static string CurrentSessionId; + private TelemetryClient _client; + private Dictionary _commonProperties; + private Dictionary _commonMeasurements; + private Task _trackEventTask; + private ILogger _logger; + private bool _initialized; + private const string InstrumentationKey = "98697a1c-1416-486a-99ac-c6c74ebe5ebd"; + /// + /// The environment variable used for opting out of telemetry + /// + public const string TelemetryOptoutEnvVar = "AZUREFUNCTIONS_SQLBINDINGS_TELEMETRY_OPTOUT"; + /// + /// The app setting used for opting out of telemetry + /// + public const string TelemetryOptoutSetting = "AzureFunctionsSqlBindingsTelemetryOptOut"; + + public const string WelcomeMessage = @"Azure SQL binding for Azure Functions +--------------------- +Telemetry +--------- +This extension collect usage data in order to help us improve your experience. The data is anonymous and doesn't include any personal information. You can opt-out of telemetry by setting the " + TelemetryOptoutEnvVar + " environment variable or the " + TelemetryOptoutSetting + @" + app setting to '1', 'true' or 'yes'; +"; + + public void Initialize(IConfiguration config, ILoggerFactory loggerFactory) + { + this._logger = loggerFactory.CreateLogger(LogCategories.Bindings); + this.Enabled = !(Utils.GetEnvironmentVariableAsBool(TelemetryOptoutEnvVar) || Utils.GetConfigSettingAsBool(TelemetryOptoutSetting, config)); + if (!this.Enabled) + { + this._logger.LogInformation("Telemetry disabled"); + return; + } + this._logger.LogInformation(WelcomeMessage); + // Store the session ID in a static field so that it can be reused + CurrentSessionId = Guid.NewGuid().ToString(); + + string productVersion = typeof(Telemetry).Assembly.GetName().Version.ToString(); + //initialize in task to offload to parallel thread + this._trackEventTask = Task.Factory.StartNew(() => this.InitializeTelemetry(productVersion)); + this._initialized = true; + } + + public bool Enabled { get; private set; } + + public void TrackEvent(string eventName, IDictionary properties, + IDictionary measurements) + { + if (!this._initialized || !this.Enabled) + { + return; + } + this._logger.LogInformation($"Sending event {eventName}"); + + //continue task in existing parallel thread + this._trackEventTask = this._trackEventTask.ContinueWith( + x => this.TrackEventTask(eventName, properties, measurements) + ); + } + + private void InitializeTelemetry(string productVersion) + { + try + { + var config = new TelemetryConfiguration(InstrumentationKey); + this._client = new TelemetryClient(config); + this._client.Context.Session.Id = CurrentSessionId; + this._client.Context.Device.OperatingSystem = RuntimeInformation.OSDescription; + + this._commonProperties = new TelemetryCommonProperties(productVersion).GetTelemetryCommonProperties(); + this._commonMeasurements = new Dictionary(); + } + catch (Exception e) + { + this._client = null; + // we don't want to fail the tool if telemetry fails. + Debug.Fail(e.ToString()); + } + } + + private void TrackEventTask( + string eventName, + IDictionary properties, + IDictionary measurements) + { + if (this._client is null) + { + return; + } + + try + { + Dictionary eventProperties = this.GetEventProperties(properties); + Dictionary eventMeasurements = this.GetEventMeasures(measurements); + + this._client.TrackEvent($"{EventsNamespace}/{eventName}", eventProperties, eventMeasurements); + this._client.Flush(); + } + catch (Exception e) + { + Debug.Fail(e.ToString()); + } + } + + private Dictionary GetEventMeasures(IDictionary measurements) + { + var eventMeasurements = new Dictionary(this._commonMeasurements); + if (measurements != null) + { + foreach (KeyValuePair measurement in measurements) + { + eventMeasurements[measurement.Key] = measurement.Value; + } + } + return eventMeasurements; + } + + private Dictionary GetEventProperties(IDictionary properties) + { + if (properties != null) + { + var eventProperties = new Dictionary(this._commonProperties); + foreach (KeyValuePair property in properties) + { + eventProperties[property.Key] = property.Value; + } + return eventProperties; + } + else + { + return this._commonProperties; + } + } + } +} + diff --git a/src/Telemetry/TelemetryCommonProperties.cs b/src/Telemetry/TelemetryCommonProperties.cs new file mode 100644 index 000000000..d384d5bf7 --- /dev/null +++ b/src/Telemetry/TelemetryCommonProperties.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql.Telemetry +{ + public class TelemetryCommonProperties + { + private readonly string _productVersion; + + public TelemetryCommonProperties( + string productVersion) + { + this._productVersion = productVersion; + } + + private const string OSVersion = "OSVersion"; + private const string ProductVersion = "ProductVersion"; + + public Dictionary GetTelemetryCommonProperties() + { + return new Dictionary + { + {OSVersion, RuntimeInformation.OSDescription}, + {ProductVersion, this._productVersion} + }; + } + } +} + diff --git a/src/Telemetry/TelemetryUtils.cs b/src/Telemetry/TelemetryUtils.cs new file mode 100644 index 000000000..d1b22f55f --- /dev/null +++ b/src/Telemetry/TelemetryUtils.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.Data.SqlClient; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql.Telemetry +{ + public static class TelemetryUtils + { + /// + /// Adds common connection properties to the property bag for a telemetry event. + /// + /// The property bag to add our connection properties to + /// The connection to add properties of + public static void AddConnectionProps(this Dictionary props, SqlConnection conn) + { + props.Add(nameof(SqlConnection.ServerVersion), conn.ServerVersion); + } + } +} \ No newline at end of file diff --git a/src/Utils.cs b/src/Utils.cs new file mode 100644 index 000000000..bb9f553ef --- /dev/null +++ b/src/Utils.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql +{ + public static class Utils + { + /// + /// Gets the specified environment variable and converts it to a boolean. + /// + /// Name of the environment variable + /// Value to use if the variable doesn't exist or is unable to be parsed + /// True if the variable exists and is set to a value that can be parsed as true, false otherwise + public static bool GetEnvironmentVariableAsBool(string name, bool defaultValue = false) + { + string str = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrEmpty(str)) + { + return defaultValue; + } + + return str.AsBool(defaultValue); + } + + /// + /// Gets the specified configuration setting and converts it to a boolean. + /// + /// Key name of the setting + /// The config option to retrieve the value from + /// Value to use if the setting doesn't exist or is unable to be parsed + /// True if the setting exists and is set to a value that can be parsed as true, false otherwise + public static bool GetConfigSettingAsBool(string name, IConfiguration config, bool defaultValue = false) + { + return config.GetValue(name, defaultValue.ToString()).AsBool(defaultValue); + } + + /// + /// Converts the string into an equivalent boolean value. This is used instead of Convert.ToBool since that + /// doesn't handle converting the string value "1". + /// + /// The string to convert + /// Value to use if the string is unable to be converted, default is false + /// + private static bool AsBool(this string str, bool defaultValue = false) + { + switch (str.ToLowerInvariant()) + { + case "true": + case "1": + case "yes": + return true; + case "false": + case "0": + case "no": + return false; + default: + return defaultValue; + } + } + } +} diff --git a/test/Common/TestConfiguration.cs b/test/Common/TestConfiguration.cs new file mode 100644 index 000000000..dc9ae267e --- /dev/null +++ b/test/Common/TestConfiguration.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common +{ + internal class TestConfiguration : IConfiguration + { + private readonly IDictionary _sections = new Dictionary(); + public void AddSection(string key, IConfigurationSection section) + { + this._sections[key] = section; + } + + string IConfiguration.this[string key] { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + IEnumerable IConfiguration.GetChildren() + { + throw new NotImplementedException(); + } + + IChangeToken IConfiguration.GetReloadToken() + { + throw new NotImplementedException(); + } + + IConfigurationSection IConfiguration.GetSection(string key) + { + return this._sections[key]; + } + } +} diff --git a/test/Common/TestConfigurationSection.cs b/test/Common/TestConfigurationSection.cs new file mode 100644 index 000000000..4b0f8362e --- /dev/null +++ b/test/Common/TestConfigurationSection.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common +{ + internal class TestConfigurationSection : IConfigurationSection + { + string IConfiguration.this[string key] { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + string IConfigurationSection.Key => throw new NotImplementedException(); + + string IConfigurationSection.Path => throw new NotImplementedException(); + + string IConfigurationSection.Value { get; set; } + + IEnumerable IConfiguration.GetChildren() + { + throw new NotImplementedException(); + } + + IChangeToken IConfiguration.GetReloadToken() + { + throw new NotImplementedException(); + } + + IConfigurationSection IConfiguration.GetSection(string key) + { + throw new NotImplementedException(); + } + } +} diff --git a/test/Unit/UtilsTests.cs b/test/Unit/UtilsTests.cs new file mode 100644 index 000000000..4fa4a2729 --- /dev/null +++ b/test/Unit/UtilsTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Unit +{ + public class UtilsTests + { + private const string TestEnvVar = "AzureFunctionsSqlBindingsTestEnvVar"; + private const string TestConfigSetting = "AzureFunctionsSqlBindingsTestConfigSetting"; + + [Theory] + [InlineData(null, false)] // Doesn't exist, get default value + [InlineData(null, true, true)] // Doesn't exist, get default value (set explicitly) + [InlineData("1", true)] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("yes", true)] + [InlineData("YES", true)] + [InlineData("0", false)] + [InlineData("false", false)] + [InlineData("FALSE", false)] + [InlineData("no", false)] + [InlineData("NO", false)] + [InlineData("2", false)] + [InlineData("SomeOtherValue", false)] + public void GetEnvironmentVariableAsBool(string value, bool expectedValue, bool defaultValue = false) + { + Environment.SetEnvironmentVariable(TestEnvVar, value?.ToString()); + bool actualValue = Utils.GetEnvironmentVariableAsBool(TestEnvVar, defaultValue); + Assert.Equal(expectedValue, actualValue); + } + + [Theory] + [InlineData(null, false)] // Doesn't exist, get default value + [InlineData(null, true, true)] // Doesn't exist, get default value (set explicitly) + [InlineData("1", true)] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("yes", true)] + [InlineData("YES", true)] + [InlineData("0", false)] + [InlineData("false", false)] + [InlineData("FALSE", false)] + [InlineData("no", false)] + [InlineData("NO", false)] + [InlineData("2", false)] + [InlineData("SomeOtherValue", false)] + public void GetConfigSettingAsBool(string value, bool expectedValue, bool defaultValue = false) + { + var config = new TestConfiguration(); + IConfigurationSection configSection = new TestConfigurationSection(); + configSection.Value = value; + config.AddSection(TestConfigSetting, configSection); + bool actualValue = Utils.GetConfigSettingAsBool(TestConfigSetting, config, defaultValue); + Assert.Equal(expectedValue, actualValue); + } + } +}