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);
+ }
+ }
+}