Initial commit: SWP.Core enterprise framework with multi-tenant architecture, configuration management, security, telemetry and comprehensive test suite
This commit is contained in:
commit
5275a75502
87 changed files with 6140 additions and 0 deletions
7
Core/CommandQueries/Command.cs
Normal file
7
Core/CommandQueries/Command.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace SWP.Core.CommandQueries;
|
||||
|
||||
public abstract class Command : ICommand
|
||||
{
|
||||
public required Guid CorrelationId { get; set; }
|
||||
public Guid TransactionId { get; set; }
|
||||
}
|
||||
42
Core/CommandQueries/CommandResponse.cs
Normal file
42
Core/CommandQueries/CommandResponse.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
namespace SWP.Core.CommandQueries;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a response to a command request
|
||||
/// This class includes details such as a unique request ID, correlation ID, command name,
|
||||
/// transaction ID, creation timestamp, and a URL to check the status of the command.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">A unique identifier used to track the request across services.</param>
|
||||
/// <param name="commandName">The name of the command being executed.</param>
|
||||
/// <param name="transactionId">An optional unique identifier for the transaction associated with the command.</param>
|
||||
public class CommandResponse(Guid correlationId, string commandName, Guid? transactionId)
|
||||
{
|
||||
/// <summary>
|
||||
/// A unique identifier for the request. This is automatically generated using Guid.CreateVersion7().
|
||||
/// </summary>
|
||||
public Guid RequestId { get; } = Guid.CreateVersion7();
|
||||
|
||||
/// <summary>
|
||||
/// A unique identifier used to track the request across services. This is provided when creating the response.
|
||||
/// </summary>
|
||||
public Guid CorrelationId { get; } = correlationId;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the command being executed.
|
||||
/// </summary>
|
||||
public string CommandName { get; } = commandName;
|
||||
|
||||
/// <summary>
|
||||
/// An optional unique identifier for the transaction associated with the command.
|
||||
/// </summary>
|
||||
public Guid? TransactionId { get; } = transactionId;
|
||||
|
||||
/// <summary>
|
||||
/// The timestamp when the command response was created. This is automatically set to the current UTC time.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// A URL where the client can check the status of the command. This is typically used in asynchronous operations.
|
||||
/// </summary>
|
||||
public string StatusUrl { get; } = "statusUrl";
|
||||
}
|
||||
7
Core/CommandQueries/ICommand.cs
Normal file
7
Core/CommandQueries/ICommand.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace SWP.Core.CommandQueries;
|
||||
|
||||
public interface ICommand
|
||||
{
|
||||
Guid CorrelationId { get; set; }
|
||||
Guid TransactionId { get; set; }
|
||||
}
|
||||
56
Core/CommandQueries/ProblemDetails.cs
Normal file
56
Core/CommandQueries/ProblemDetails.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
namespace SWP.Core.CommandQueries;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a standardized error response according to RFC 9457 (Problem Details for HTTP APIs).
|
||||
/// This class provides a consistent way to communicate errors in HTTP APIs, including details about the error type,
|
||||
/// status code, and additional context. It also supports extensions for custom error information.
|
||||
///
|
||||
/// RFC 9457 Documentation: https://www.rfc-editor.org/rfc/rfc9457.html
|
||||
/// </summary>
|
||||
public class ProblemDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// A URI reference that identifies the problem type. This is typically a link to human-readable documentation about the error.
|
||||
/// </summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A short, human-readable summary of the problem. It should not change between occurrences of the same error.
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP status code generated by the server for this occurrence of the problem. This allows the client to understand the general category of the error.
|
||||
/// </summary>
|
||||
public int? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A human-readable explanation specific to this occurrence of the problem. It provides additional details about the error.
|
||||
/// </summary>
|
||||
public string Detail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A URI reference that identifies the specific occurrence of the problem. This can be used to trace the error in logs or debugging tools.
|
||||
/// </summary>
|
||||
public string Instance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A dictionary for additional, custom error information. This allows extending the problem details with application-specific fields.
|
||||
/// </summary>
|
||||
[Newtonsoft.Json.JsonExtensionData]
|
||||
public Dictionary<string, object> Extensions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom extension to the problem details.
|
||||
/// </summary>
|
||||
/// <param name="key">The key for the extension.</param>
|
||||
/// <param name="value">The value of the extension.</param>
|
||||
public void AddExtension(string key, object value) => Extensions.Add(key, value);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes a custom extension from the problem details.
|
||||
/// </summary>
|
||||
/// <param name="key">The key of the extension to remove.</param>
|
||||
public void RemoveExtension(string key) => Extensions.Remove(key);
|
||||
}
|
||||
75
Core/Configurations/Common/KeyValueToJson.cs
Normal file
75
Core/Configurations/Common/KeyValueToJson.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SWP.Core.Configurations.Common
|
||||
{
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Collections.Generic;
|
||||
|
||||
public static class KeyValueToJson
|
||||
{
|
||||
public static JObject Convert(IEnumerable<KeyValuePair<string, JToken>> pairs)
|
||||
{
|
||||
var root = new JObject();
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var keys = pair.Key.Split(':');
|
||||
var current = root;
|
||||
|
||||
// Gennemgå hierarkiet og opret underobjekter, hvis de ikke eksisterer
|
||||
for (int i = 0; i < keys.Length - 1; i++)
|
||||
{
|
||||
var key = keys[i];
|
||||
|
||||
if (current[key] == null)
|
||||
current[key] = new JObject();
|
||||
|
||||
current = (JObject)current[key];
|
||||
}
|
||||
|
||||
// Håndter den sidste nøgle og tilføj værdien
|
||||
var lastKey = keys[keys.Length - 1];
|
||||
var value = ConvertValue(pair.Value);
|
||||
|
||||
// Hvis den sidste nøgle allerede eksisterer, tilføj til en liste
|
||||
if (current[lastKey] != null)
|
||||
// Hvis den allerede er en liste, tilføj til listen
|
||||
if (current[lastKey].Type == JTokenType.Array)
|
||||
((JArray)current[lastKey]).Add(value);
|
||||
// Hvis den ikke er en liste, konverter til en liste
|
||||
else
|
||||
{
|
||||
var existingValue = current[lastKey];
|
||||
current[lastKey] = new JArray { existingValue, value };
|
||||
}
|
||||
// Ellers tilføj som en enkelt værdi
|
||||
else
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static JToken ConvertValue(object value)
|
||||
{
|
||||
// Hvis værdien allerede er en JToken, returner den direkte
|
||||
if (value is JToken token)
|
||||
return token;
|
||||
|
||||
// Konverter andre typer
|
||||
return value switch
|
||||
{
|
||||
int i => new JValue(i),
|
||||
double d => new JValue(d),
|
||||
bool b => new JValue(b),
|
||||
string s => new JValue(s),
|
||||
_ => new JValue(value.ToString())
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
152
Core/Configurations/ConfigurationBuilder.cs
Normal file
152
Core/Configurations/ConfigurationBuilder.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace SWP.Core.Configurations
|
||||
{
|
||||
public interface IConfigurationBuilder
|
||||
{
|
||||
ConfigurationBuilder AddProvider(IConfigurationProvider provider);
|
||||
IConfigurationRoot Build();
|
||||
List<IConfigurationProvider> ConfigurationProviders { get; }
|
||||
}
|
||||
|
||||
public class ConfigurationBuilder : IConfigurationBuilder
|
||||
{
|
||||
public List<IConfigurationProvider> ConfigurationProviders { get; private set; } = [];
|
||||
|
||||
public ConfigurationBuilder AddProvider(IConfigurationProvider provider)
|
||||
{
|
||||
((IConfigurationBuilder)this).ConfigurationProviders.Add(provider);
|
||||
return this;
|
||||
}
|
||||
public IConfigurationRoot Build()
|
||||
{
|
||||
foreach (var provider in ConfigurationProviders)
|
||||
provider.Build();
|
||||
//TODO: we need to come up with merge strategy, right now the latest key-path dominates
|
||||
|
||||
return new ConfigurationRoot(ConfigurationProviders);
|
||||
}
|
||||
}
|
||||
|
||||
public class Configuration : IConfiguration
|
||||
{
|
||||
List<IConfigurationProvider> _providers = [];
|
||||
|
||||
/// <summary>
|
||||
/// Implements a string-based indexer for backwards compatibility with Microsoft.Extensions.Configuration.
|
||||
/// This implementation is marked as obsolete and should be replaced with type-safe alternatives.
|
||||
/// </summary>
|
||||
/// <param name="key">The configuration key to retrieve.</param>
|
||||
/// <returns>The configuration value for the specified key.</returns>
|
||||
/// <exception cref="NotSupportedException">Thrown when attempting to set a value, as this operation is not supported.</exception>
|
||||
[Obsolete("Use type-safe configuration methods instead")]
|
||||
public string this[string key]
|
||||
{
|
||||
get => GetConfiguration(_providers, key);
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
List<IConfigurationProvider> IConfiguration.ConfigurationProviders
|
||||
{
|
||||
get { return _providers; }
|
||||
set { _providers = value; }
|
||||
}
|
||||
|
||||
internal static string GetConfiguration(IList<IConfigurationProvider> providers, string key)
|
||||
{
|
||||
string value = null;
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
var test = provider.Configuration().SelectToken(ConfigurationBinder.NormalizePath(key));
|
||||
|
||||
if (test != null)
|
||||
value = test.ToString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public class ConfigurationRoot : Configuration, IConfigurationRoot
|
||||
{
|
||||
public ConfigurationRoot(List<IConfigurationProvider> configurationProviders)
|
||||
{
|
||||
((IConfiguration)this).ConfigurationProviders = configurationProviders;
|
||||
}
|
||||
|
||||
}
|
||||
public static class ConfigurationBinder
|
||||
{
|
||||
public static string NormalizePath(string path)
|
||||
{
|
||||
return path?.Replace(":", ".", StringComparison.Ordinal) ?? string.Empty;
|
||||
}
|
||||
public static string GetConnectionString(this IConfigurationRoot configuration, string name)
|
||||
{
|
||||
return configuration.GetSection("ConnectionStrings").Get<string>(name);
|
||||
}
|
||||
public static IConfigurationSection GetSection(this IConfigurationRoot configuration, string path)
|
||||
{
|
||||
JToken value = null;
|
||||
foreach (var provider in configuration.ConfigurationProviders)
|
||||
{
|
||||
var test = provider.Configuration().SelectToken(NormalizePath(path));
|
||||
|
||||
if (test != null)
|
||||
value = test;
|
||||
}
|
||||
|
||||
return new ConfigurationSection { Path = path, Key = path.Split(':').Last(), Value = value };
|
||||
}
|
||||
public static T Get<T>(this IConfigurationRoot configuration, string path)
|
||||
{
|
||||
JToken value = null;
|
||||
foreach (var provider in configuration.ConfigurationProviders)
|
||||
{
|
||||
var test = provider.Configuration().SelectToken(NormalizePath(path));
|
||||
|
||||
if (test != null)
|
||||
value = test;
|
||||
}
|
||||
|
||||
return value.ToObject<T>();
|
||||
}
|
||||
public static T Get<T>(this IConfigurationSection configuration, string path)
|
||||
{
|
||||
var value = configuration.Value.SelectToken(NormalizePath(path)).ToObject<T>();
|
||||
return value;
|
||||
}
|
||||
public static T ToObject<T>(this IConfigurationSection configuration)
|
||||
{
|
||||
var value = configuration.Value.ToObject<T>();
|
||||
return value;
|
||||
}
|
||||
|
||||
[Obsolete("Use ToObject")]
|
||||
public static T Get<T>(this IConfigurationSection configuration)
|
||||
{
|
||||
return configuration.Value.ToObject<T>();
|
||||
}
|
||||
|
||||
}
|
||||
public interface IConfigurationProvider
|
||||
{
|
||||
void Build();
|
||||
JObject Configuration();
|
||||
}
|
||||
|
||||
public class ConfigurationSection : IConfigurationSection
|
||||
{
|
||||
public required string Path { get; set; }
|
||||
public required string Key { get; set; }
|
||||
|
||||
public required JToken Value { get; set; }
|
||||
|
||||
}
|
||||
public interface IConfigurationSection
|
||||
{
|
||||
string Path { get; }
|
||||
string Key { get; }
|
||||
JToken Value { get; set; }
|
||||
}
|
||||
}
|
||||
9
Core/Configurations/IAppConfiguration.cs
Normal file
9
Core/Configurations/IAppConfiguration.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace SWP.Core.Configurations
|
||||
{
|
||||
/// <summary>
|
||||
/// Marker interface for application configurations that should be automatically registered in the DI container.
|
||||
/// Classes implementing this interface will be loaded from configuration and registered as singletons.
|
||||
/// </summary>
|
||||
public interface IAppConfiguration { }
|
||||
|
||||
}
|
||||
10
Core/Configurations/IConfigurationRoot.cs
Normal file
10
Core/Configurations/IConfigurationRoot.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace SWP.Core.Configurations
|
||||
{
|
||||
public interface IConfigurationRoot : IConfiguration { }
|
||||
|
||||
public interface IConfiguration
|
||||
{
|
||||
internal List<IConfigurationProvider> ConfigurationProviders { get; set; }
|
||||
string this[string key] { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using SWP.Core.Exceptions;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace SWP.Core.Configurations.JsonConfigProvider
|
||||
{
|
||||
public static class JsonConfigExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a JSON configuration source to the configuration builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The configuration builder to add to</param>
|
||||
/// <param name="configurationFilePath">Path to the JSON configuration file. Defaults to "appconfiguration.json"</param>
|
||||
/// <param name="optional">If true, the configuration file is optional. Defaults to true</param>
|
||||
/// <param name="reloadOnChange">If true, the configuration will be reloaded when the file changes. Defaults to false</param>
|
||||
/// <returns>The configuration builder</returns>
|
||||
public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, string configurationFilePath = "appconfiguration.json", bool? optional = true, bool? reloadOnChange = false)
|
||||
{
|
||||
return builder.AddProvider(new JsonConfigProvider(builder, configurationFilePath, optional ?? true, reloadOnChange ?? false));
|
||||
}
|
||||
}
|
||||
|
||||
public interface IHasConfigurationFilePath
|
||||
{
|
||||
string ConfigurationFilePath { get; }
|
||||
}
|
||||
public class JsonConfigProvider : IConfigurationProvider, IHasConfigurationFilePath
|
||||
{
|
||||
private readonly IConfigurationBuilder _builder;
|
||||
private readonly bool _reloadOnChange;
|
||||
JObject _configuration;
|
||||
public string ConfigurationFilePath { get; private set; }
|
||||
|
||||
public JsonConfigProvider() { }
|
||||
|
||||
public JsonConfigProvider(IConfigurationBuilder builder, string configurationFilePath, bool optional, bool reloadOnChange)
|
||||
{
|
||||
if (!optional && !File.Exists(configurationFilePath))
|
||||
throw new ConfigurationException($"File not found, path: {configurationFilePath}");
|
||||
if (optional && !File.Exists(configurationFilePath))
|
||||
return;
|
||||
|
||||
ConfigurationFilePath = configurationFilePath;
|
||||
_builder = builder;
|
||||
_reloadOnChange = reloadOnChange;
|
||||
}
|
||||
|
||||
public void Build()
|
||||
{
|
||||
using (StreamReader file = File.OpenText(ConfigurationFilePath))
|
||||
using (JsonTextReader reader = new JsonTextReader(file))
|
||||
_configuration = (JObject)JToken.ReadFrom(reader);
|
||||
}
|
||||
|
||||
public JObject Configuration()
|
||||
{
|
||||
return _configuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Core/Configurations/SmartConfigProvider/AppConfiguration.cs
Normal file
14
Core/Configurations/SmartConfigProvider/AppConfiguration.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace SWP.Core.Configurations.SmartConfigProvider;
|
||||
public class AppConfiguration
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Key { get; set; }
|
||||
public object Value { get; set; }
|
||||
public string Label { get; set; }
|
||||
public string ContentType { get; set; }
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? ModifiedAt { get; set; }
|
||||
public Guid? Etag { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace SWP.Core.Configurations.SmartConfigProvider;
|
||||
public interface IConfigurationRepository
|
||||
{
|
||||
string ConnectionString { get; set; }
|
||||
IEnumerable<AppConfiguration> GetActiveConfigurations();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
using SWP.Core.Configurations.SmartConfigProvider;
|
||||
|
||||
namespace SWP.Core.Configurations.SmartConfigProvider.Repositories;
|
||||
public class PostgresConfigurationRepository : IConfigurationRepository
|
||||
{
|
||||
private IDbConnection _connection;
|
||||
public string ConnectionString { get; set; }
|
||||
|
||||
|
||||
public PostgresConfigurationRepository(string connectionString)
|
||||
{
|
||||
_connection = new Npgsql.NpgsqlConnection(connectionString);
|
||||
}
|
||||
public PostgresConfigurationRepository()
|
||||
{
|
||||
|
||||
}
|
||||
public IEnumerable<AppConfiguration> GetActiveConfigurations()
|
||||
{
|
||||
_connection ??= new Npgsql.NpgsqlConnection(ConnectionString);
|
||||
|
||||
const string sql = @"
|
||||
SELECT id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
FROM app_configuration
|
||||
WHERE CURRENT_TIMESTAMP BETWEEN valid_from AND expires_at
|
||||
OR (valid_from IS NULL AND expires_at IS NULL)";
|
||||
|
||||
|
||||
return _connection.QuerySql<AppConfiguration>(sql);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
namespace SWP.Core.Configurations.SmartConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for adding smart configuration providers to IConfigurationBuilder.
|
||||
/// </summary>
|
||||
public static class SmartConfigExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a smart configuration provider using a connection string from appsettings.
|
||||
/// </summary>
|
||||
/// <param name="builder">The configuration builder to add to</param>
|
||||
/// <param name="configKey">The key to find the connection string in the ConnectionStrings section. Defaults to "DefaultConnection"</param>
|
||||
/// <param name="path">Optional path to configuration file if different from default appsettings location</param>
|
||||
/// <returns>The configuration builder</returns>
|
||||
public static IConfigurationBuilder AddSmartConfig(this IConfigurationBuilder builder, string configKey = "DefaultConnection", string path = null)
|
||||
{
|
||||
return builder.AddProvider(new SmartConfigProvider(builder, configKey, path));
|
||||
}
|
||||
/// <summary>
|
||||
/// Adds a smart configuration provider with custom configuration options.
|
||||
/// </summary>
|
||||
/// <param name="builder">The configuration builder to add to</param>
|
||||
/// <param name="setupAction">Action to configure the smart configuration options</param>
|
||||
/// <returns>The configuration builder</returns>
|
||||
public static IConfigurationBuilder AddSmartConfig(this IConfigurationBuilder builder, Action<SmartConfigOptions> setupAction)
|
||||
{
|
||||
var options = new SmartConfigOptions();
|
||||
setupAction(options);
|
||||
|
||||
return builder.AddProvider(new SmartConfigProvider(builder, options));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
namespace SWP.Core.Configurations.SmartConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration options for setting up smart configuration providers.
|
||||
/// Provides fluent configuration methods for specifying the repository type and settings.
|
||||
/// </summary>
|
||||
public class SmartConfigOptions
|
||||
{
|
||||
private IConfigurationRepository _repository;
|
||||
internal string _configKey;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the smart configuration to use PostgreSQL as the configuration store.
|
||||
/// </summary>
|
||||
/// <param name="configKey">The configuration key used to find the connection string</param>
|
||||
/// <returns>The configuration options instance for method chaining</returns>
|
||||
public SmartConfigOptions UsePostgres(string configKey)
|
||||
{
|
||||
_configKey = configKey;
|
||||
_repository = new Repositories.PostgresConfigurationRepository();
|
||||
return this;
|
||||
}
|
||||
/// <summary>
|
||||
/// Configures the smart configuration to use SQL Server as the configuration store.
|
||||
/// </summary>
|
||||
/// <returns>The configuration options instance for method chaining</returns>
|
||||
/// <exception cref="NotImplementedException">This feature is not yet implemented</exception>
|
||||
public SmartConfigOptions UseSqlServer()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
/// <summary>
|
||||
/// Configures the smart configuration to use a custom configuration repository.
|
||||
/// </summary>
|
||||
/// <param name="repository">The configuration repository to use</param>
|
||||
/// <returns>The configuration options instance for method chaining</returns>
|
||||
public SmartConfigOptions UseRepository(IConfigurationRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
return this;
|
||||
}
|
||||
|
||||
internal IConfigurationRepository GetRepository() => _repository;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SWP.Core.Exceptions;
|
||||
using SWP.Core.Configurations.JsonConfigProvider;
|
||||
|
||||
namespace SWP.Core.Configurations.SmartConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration provider that loads configuration from a smart configuration source (e.g. database).
|
||||
/// The provider reads connection details from a JSON file and uses them to connect to a configuration repository.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The provider supports multiple initialization methods:
|
||||
/// - Through SmartConfigOptions for flexible repository configuration
|
||||
/// - Through direct configuration key and file path
|
||||
/// Configuration is loaded from the repository during Build() and converted to a JSON structure.
|
||||
/// </remarks>
|
||||
public class SmartConfigProvider : IConfigurationProvider
|
||||
{
|
||||
string _configKey;
|
||||
string _connectionString;
|
||||
string _path;
|
||||
IConfigurationBuilder _builder;
|
||||
|
||||
JObject _configuration;
|
||||
SmartConfigOptions _smartConfigOptions;
|
||||
|
||||
public SmartConfigProvider() { }
|
||||
|
||||
public SmartConfigProvider(IConfigurationBuilder builder, SmartConfigOptions smartConfigOptions)
|
||||
{
|
||||
_builder = builder;
|
||||
_smartConfigOptions = smartConfigOptions;
|
||||
_configKey = smartConfigOptions._configKey;
|
||||
SetConnectionString();
|
||||
|
||||
}
|
||||
public SmartConfigProvider(IConfigurationBuilder builder, string configKey, string configurationFilePath)
|
||||
{
|
||||
_builder = builder;
|
||||
_configKey = configKey;
|
||||
_path = configurationFilePath;
|
||||
SetConnectionString();
|
||||
}
|
||||
|
||||
void SetConnectionString()
|
||||
{
|
||||
var carrier = _builder.ConfigurationProviders.OfType<IHasConfigurationFilePath>().SingleOrDefault();
|
||||
|
||||
if (carrier?.ConfigurationFilePath is null && _path is null)
|
||||
throw new ConfigurationException($"Expected a previous added ConfigurationProvider with IHasConfigurationFilePath or a configurationFilePath where to find the appsettingsfile");
|
||||
|
||||
_path ??= carrier.ConfigurationFilePath;
|
||||
|
||||
if (!File.Exists(_path))
|
||||
throw new ConfigurationException($"File not found, configurationFilePath: {_path}");
|
||||
|
||||
|
||||
using (StreamReader file = File.OpenText(_path))
|
||||
using (JsonTextReader reader = new JsonTextReader(file))
|
||||
{
|
||||
var jsonConfiguration = (JObject)JToken.ReadFrom(reader);
|
||||
|
||||
_connectionString = jsonConfiguration.SelectToken($"ConnectionStrings.{_configKey}")?.ToString();
|
||||
}
|
||||
}
|
||||
public void Build()
|
||||
{
|
||||
var repository = _smartConfigOptions.GetRepository();
|
||||
repository.ConnectionString = _connectionString;
|
||||
|
||||
var configs = repository.GetActiveConfigurations();
|
||||
|
||||
var pairs = configs.Select(x => new KeyValuePair<string, JToken>(x.Key, JToken.Parse(x.Value.ToString())));
|
||||
|
||||
_configuration = Common.KeyValueToJson.Convert(pairs);
|
||||
|
||||
}
|
||||
|
||||
public JObject Configuration()
|
||||
{
|
||||
return _configuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
using Insight.Database;
|
||||
using System.Data;
|
||||
using SWP.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace SWP.Core.Database.ConfigurationManagementSystem;
|
||||
|
||||
public class SetupConfiguration(IDbConnectionFactory connectionFactory) : IDbConfigure<SetupConfiguration.Command>
|
||||
{
|
||||
public class Command { }
|
||||
|
||||
public void With(Command notInUse, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
using var conn = parameters is null ? connectionFactory.Create() : connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
CreateConfigurationTable(conn);
|
||||
CreateHistoryTable(conn);
|
||||
CreateConfigurationIndexes(conn);
|
||||
CreateModifiedAtTrigger(conn);
|
||||
CreateNotifyTrigger(conn);
|
||||
CreateHistoryTrigger(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupConfiguration in Database", ex);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void CreateConfigurationTable(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE TABLE IF NOT EXISTS app_configuration (
|
||||
id bigserial NOT NULL,
|
||||
""key"" varchar(255) NOT NULL,
|
||||
value text NULL,
|
||||
""label"" varchar(255) NULL,
|
||||
content_type varchar(255) DEFAULT 'text/plain'::character varying NULL,
|
||||
valid_from timestamptz NULL,
|
||||
expires_at timestamptz NULL,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
|
||||
modified_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
|
||||
etag uuid DEFAULT gen_random_uuid() NULL,
|
||||
CONSTRAINT app_configuration_pkey PRIMARY KEY (id)
|
||||
);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateHistoryTable(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE TABLE IF NOT EXISTS app_configuration_history (
|
||||
history_id bigserial NOT NULL,
|
||||
action_type char(1) NOT NULL,
|
||||
action_timestamp timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
action_by text NOT NULL DEFAULT CURRENT_USER,
|
||||
id bigint NOT NULL,
|
||||
""key"" varchar(255) NOT NULL,
|
||||
value text NULL,
|
||||
""label"" varchar(255) NULL,
|
||||
content_type varchar(255) NULL,
|
||||
valid_from timestamptz NULL,
|
||||
expires_at timestamptz NULL,
|
||||
created_at timestamptz NULL,
|
||||
modified_at timestamptz NULL,
|
||||
etag uuid NULL,
|
||||
CONSTRAINT app_configuration_history_pkey PRIMARY KEY (history_id)
|
||||
);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateConfigurationIndexes(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE INDEX IF NOT EXISTS idx_app_configuration_key ON app_configuration(""key"");
|
||||
CREATE INDEX IF NOT EXISTS idx_app_configuration_validity ON app_configuration(valid_from, expires_at);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateModifiedAtTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION update_app_configuration_modified_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.modified_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_modified_at
|
||||
BEFORE UPDATE ON app_configuration
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_app_configuration_modified_at();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateNotifyTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION notify_app_configuration_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('config_changes', NEW.key);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_notify
|
||||
AFTER INSERT OR UPDATE ON app_configuration
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION notify_app_configuration_change();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateHistoryTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION log_app_configuration_changes()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'I', NEW.id, NEW.key, NEW.value, NEW.label, NEW.content_type,
|
||||
NEW.valid_from, NEW.expires_at, NEW.created_at, NEW.modified_at, NEW.etag
|
||||
);
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'U', OLD.id, OLD.key, OLD.value, OLD.label, OLD.content_type,
|
||||
OLD.valid_from, OLD.expires_at, OLD.created_at, OLD.modified_at, OLD.etag
|
||||
);
|
||||
ELSIF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'D', OLD.id, OLD.key, OLD.value, OLD.label, OLD.content_type,
|
||||
OLD.valid_from, OLD.expires_at, OLD.created_at, OLD.modified_at, OLD.etag
|
||||
);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_history
|
||||
AFTER INSERT OR UPDATE OR DELETE ON app_configuration
|
||||
FOR EACH ROW EXECUTE FUNCTION log_app_configuration_changes();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
8
Core/Database/ConnectionFactory/IDbConnectionFactory.cs
Normal file
8
Core/Database/ConnectionFactory/IDbConnectionFactory.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace SWP.Core.Database.ConnectionFactory
|
||||
{
|
||||
public interface IDbConnectionFactory
|
||||
{
|
||||
System.Data.IDbConnection Create();
|
||||
System.Data.IDbConnection Create(ConnectionStringParameters connectionStringTemplateParameters);
|
||||
}
|
||||
}
|
||||
65
Core/Database/ConnectionFactory/PostgresConnectionFactory.cs
Normal file
65
Core/Database/ConnectionFactory/PostgresConnectionFactory.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System.Data;
|
||||
using Npgsql;
|
||||
|
||||
namespace SWP.Core.Database.ConnectionFactory
|
||||
{
|
||||
|
||||
public record ConnectionStringParameters(string User, string Pwd);
|
||||
|
||||
public class PostgresConnectionFactory : IDbConnectionFactory, IAsyncDisposable
|
||||
{
|
||||
private readonly NpgsqlDataSource _baseDataSource;
|
||||
private readonly Action<NpgsqlDataSourceBuilder> _configureDataSource;
|
||||
private readonly Microsoft.Extensions.Logging.ILoggerFactory _loggerFactory; //this is not tested nor implemented, I just created it as an idea
|
||||
|
||||
public PostgresConnectionFactory(
|
||||
string connectionString,
|
||||
Microsoft.Extensions.Logging.ILoggerFactory loggerFactory = null,
|
||||
Action<NpgsqlDataSourceBuilder> configureDataSource = null)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_configureDataSource = configureDataSource ?? (builder => { });
|
||||
|
||||
// Opret base data source med konfiguration
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||||
ConfigureDataSourceBuilder(dataSourceBuilder);
|
||||
_baseDataSource = dataSourceBuilder.Build();
|
||||
}
|
||||
|
||||
public IDbConnection Create()
|
||||
{
|
||||
return _baseDataSource.CreateConnection();
|
||||
}
|
||||
|
||||
public IDbConnection Create(ConnectionStringParameters param)
|
||||
{
|
||||
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(
|
||||
_baseDataSource.ConnectionString)
|
||||
{
|
||||
Username = param.User,
|
||||
Password = param.Pwd
|
||||
};
|
||||
|
||||
var tempDataSourceBuilder = new NpgsqlDataSourceBuilder(
|
||||
connectionStringBuilder.ToString());
|
||||
|
||||
ConfigureDataSourceBuilder(tempDataSourceBuilder);
|
||||
|
||||
var tempDataSource = tempDataSourceBuilder.Build();
|
||||
return tempDataSource.CreateConnection();
|
||||
}
|
||||
|
||||
private void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
if (_loggerFactory != null)
|
||||
builder.UseLoggerFactory(_loggerFactory);
|
||||
|
||||
_configureDataSource?.Invoke(builder);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _baseDataSource.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Core/Database/DatabaseScope.cs
Normal file
38
Core/Database/DatabaseScope.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace SWP.Core.Database;
|
||||
|
||||
public class DatabaseScope : IDisposable
|
||||
{
|
||||
internal readonly IOperationHolder<DependencyTelemetry> _operation;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
|
||||
public DatabaseScope(IDbConnection connection, IOperationHolder<DependencyTelemetry> operation)
|
||||
{
|
||||
Connection = connection;
|
||||
_operation = operation;
|
||||
_operation.Telemetry.Success = true;
|
||||
_operation.Telemetry.Timestamp = DateTimeOffset.UtcNow;
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
public IDbConnection Connection { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stopwatch.Stop();
|
||||
_operation.Telemetry.Duration = _stopwatch.Elapsed;
|
||||
|
||||
_operation.Dispose();
|
||||
Connection.Dispose();
|
||||
}
|
||||
|
||||
public void Error(Exception ex)
|
||||
{
|
||||
_operation.Telemetry.Success = false;
|
||||
_operation.Telemetry.Properties["Error"] = ex.Message;
|
||||
}
|
||||
}
|
||||
10
Core/Database/IDatabaseOperations.cs
Normal file
10
Core/Database/IDatabaseOperations.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using System.Data;
|
||||
|
||||
namespace SWP.Core.Database;
|
||||
|
||||
public interface IDatabaseOperations
|
||||
{
|
||||
DatabaseScope CreateScope(string operationName);
|
||||
Task<T> ExecuteAsync<T>(Func<IDbConnection, Task<T>> operation, string operationName);
|
||||
Task ExecuteAsync(Func<IDbConnection, Task> operation, string operationName);
|
||||
}
|
||||
9
Core/Database/IDbConfigure.cs
Normal file
9
Core/Database/IDbConfigure.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using SWP.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace SWP.Core.Database
|
||||
{
|
||||
public interface IDbConfigure<T>
|
||||
{
|
||||
void With(T command, ConnectionStringParameters parameters = null);
|
||||
}
|
||||
}
|
||||
25
Core/Database/ModuleRegistry/DbPostgreSqlModule.cs
Normal file
25
Core/Database/ModuleRegistry/DbPostgreSqlModule.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using Autofac;
|
||||
using SWP.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace SWP.Core.Database.ModuleRegistry
|
||||
{
|
||||
|
||||
public class DbPostgreSqlModule : Module
|
||||
{
|
||||
public required string ConnectionString { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
Insight.Database.Providers.PostgreSQL.PostgreSQLInsightDbProvider.RegisterProvider();
|
||||
|
||||
builder.RegisterType<PostgresConnectionFactory>()
|
||||
.As<IDbConnectionFactory>()
|
||||
.WithParameter(new TypedParameter(typeof(string), ConnectionString))
|
||||
.SingleInstance();
|
||||
|
||||
builder.RegisterType<SqlOperations>()
|
||||
.As<IDatabaseOperations>();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
57
Core/Database/SqlOperations.cs
Normal file
57
Core/Database/SqlOperations.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using System.Data;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using SWP.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace SWP.Core.Database;
|
||||
|
||||
public class SqlOperations : IDatabaseOperations
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
|
||||
public SqlOperations(IDbConnectionFactory connectionFactory, TelemetryClient telemetryClient)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_telemetryClient = telemetryClient;
|
||||
}
|
||||
|
||||
public DatabaseScope CreateScope(string operationName)
|
||||
{
|
||||
var connection = _connectionFactory.Create();
|
||||
var operation = _telemetryClient.StartOperation<DependencyTelemetry>(operationName);
|
||||
operation.Telemetry.Type = "SQL";
|
||||
operation.Telemetry.Target = "PostgreSQL";
|
||||
|
||||
return new DatabaseScope(connection, operation);
|
||||
}
|
||||
|
||||
public async Task<T> ExecuteAsync<T>(Func<IDbConnection, Task<T>> operation, string operationName)
|
||||
{
|
||||
using var scope = CreateScope(operationName);
|
||||
try
|
||||
{
|
||||
var result = await operation(scope.Connection);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
scope.Error(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(Func<IDbConnection, Task> operation, string operationName)
|
||||
{
|
||||
using var scope = CreateScope(operationName);
|
||||
try
|
||||
{
|
||||
await operation(scope.Connection);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
scope.Error(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
Core/Entities/Users/User.cs
Normal file
35
Core/Entities/Users/User.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SWP.Core.Entities.Users
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string PasswordHash { get; set; }
|
||||
public string SecurityStamp { get; set; }
|
||||
public bool EmailConfirmed { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
}
|
||||
|
||||
public class Organization
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string ConnectionString { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public int CreatedBy { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class UserOrganization
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
public int OrganizationId { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
}
|
||||
}
|
||||
9
Core/Exceptions/ConfigurationException.cs
Normal file
9
Core/Exceptions/ConfigurationException.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace SWP.Core.Exceptions
|
||||
{
|
||||
internal class ConfigurationException : Exception
|
||||
{
|
||||
public ConfigurationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Core/ISecureTokenizer.cs
Normal file
8
Core/ISecureTokenizer.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace SWP.Core
|
||||
{
|
||||
public interface ISecureTokenizer
|
||||
{
|
||||
string TokenizeText(string word);
|
||||
bool VerifyToken(string hash, string word);
|
||||
}
|
||||
}
|
||||
14
Core/ModuleRegistry/SecurityModule.cs
Normal file
14
Core/ModuleRegistry/SecurityModule.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using Autofac;
|
||||
using SWP.Core.SeqLogging;
|
||||
|
||||
namespace SWP.Core.ModuleRegistry
|
||||
{
|
||||
public class SecurityModule : Module
|
||||
{
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
builder.RegisterType<SecureTokenizer>()
|
||||
.As<ISecureTokenizer>();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Core/ModuleRegistry/SeqLoggingModule.cs
Normal file
31
Core/ModuleRegistry/SeqLoggingModule.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using Autofac;
|
||||
using SWP.Core.SeqLogging;
|
||||
|
||||
namespace SWP.Core.ModuleRegistry
|
||||
{
|
||||
public class SeqLoggingModule : Module
|
||||
{
|
||||
public required SeqConfiguration SeqConfiguration { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
|
||||
//builder.RegisterType<MessageChannel>()
|
||||
// .As<IMessageChannel<Microsoft.ApplicationInsights.Channel.ITelemetry>>()
|
||||
// .SingleInstance();
|
||||
|
||||
builder.RegisterType<SeqBackgroundService>()
|
||||
//.As<Microsoft.Extensions.Hosting.IHostedService>()
|
||||
.SingleInstance();
|
||||
|
||||
builder.RegisterGeneric(typeof(SeqLogger<>));
|
||||
|
||||
builder.RegisterInstance(SeqConfiguration);
|
||||
|
||||
builder.RegisterType<SeqHttpClient>()
|
||||
.As<SeqHttpClient>()
|
||||
.SingleInstance();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Core/ModuleRegistry/TelemetryModule.cs
Normal file
50
Core/ModuleRegistry/TelemetryModule.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using Autofac;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using SWP.Core.Telemetry;
|
||||
|
||||
namespace SWP.Core.ModuleRegistry
|
||||
{
|
||||
public class TelemetryModule : Module
|
||||
{
|
||||
public required TelemetryConfig TelemetryConfig { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
var configuration = TelemetryConfiguration.CreateDefault();
|
||||
configuration.ConnectionString = TelemetryConfig.ConnectionString;
|
||||
configuration.TelemetryChannel.DeveloperMode = true;
|
||||
|
||||
var client = new Microsoft.ApplicationInsights.TelemetryClient(configuration);
|
||||
client.Context.GlobalProperties["Application"] = GetType().Namespace?.Split('.')[0];
|
||||
client.Context.GlobalProperties["MachineName"] = Environment.MachineName;
|
||||
client.Context.GlobalProperties["CLRVersion"] = Environment.Version.ToString();
|
||||
client.Context.GlobalProperties["ProcessorCount"] = Environment.ProcessorCount.ToString();
|
||||
|
||||
builder.Register(c => client).InstancePerLifetimeScope();
|
||||
|
||||
if (TelemetryConfig.UseSeqLoggingTelemetryChannel)
|
||||
{
|
||||
var messageChannel = new MessageChannel();
|
||||
|
||||
builder.RegisterInstance(messageChannel)
|
||||
.As<IMessageChannel<ITelemetry>>()
|
||||
.SingleInstance();
|
||||
|
||||
configuration.TelemetryChannel = new SeqTelemetryChannel(messageChannel, client);
|
||||
}
|
||||
|
||||
var telemetryProcessorChain =
|
||||
new Microsoft.ApplicationInsights.Extensibility.Implementation.TelemetryProcessorChainBuilder(
|
||||
configuration);
|
||||
telemetryProcessorChain.Use(next => new Telemetry.Enrichers.EnrichWithMetaTelemetry(next));
|
||||
telemetryProcessorChain.Build();
|
||||
}
|
||||
}
|
||||
|
||||
public class TelemetryConfig
|
||||
{
|
||||
public string ConnectionString { get; set; }
|
||||
public bool UseSeqLoggingTelemetryChannel { get; set; }
|
||||
}
|
||||
}
|
||||
28
Core/MultiKeyEncryption/MasterKey.cs
Normal file
28
Core/MultiKeyEncryption/MasterKey.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace SWP.Core.MultiKeyEncryption
|
||||
{
|
||||
internal class MasterKey
|
||||
{
|
||||
public async Task RotateMasterKey(int tenantId, string oldMasterKey, string newMasterKey)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
// Hent alle bruger-keys for tenant
|
||||
//var users = await GetTenantUsers(tenantId);
|
||||
|
||||
//// Dekrypter connection string med gammel master key
|
||||
//var connString = DecryptWithKey(encryptedConnString, oldMasterKey);
|
||||
|
||||
//// Krypter med ny master key
|
||||
//var newEncryptedConnString = EncryptWithKey(connString, newMasterKey);
|
||||
|
||||
//// Re-krypter master key for alle brugere
|
||||
//foreach (var user in users)
|
||||
//{
|
||||
// var userKey = DeriveKeyFromPassword(user.Password);
|
||||
// var newEncryptedMasterKey = EncryptWithKey(newMasterKey, userKey);
|
||||
// await UpdateUserMasterKey(user.UserId, newEncryptedMasterKey);
|
||||
//}
|
||||
|
||||
//await UpdateTenantConnectionString(tenantId, newEncryptedConnString);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
Core/MultiKeyEncryption/SecureConnectionString.cs
Normal file
98
Core/MultiKeyEncryption/SecureConnectionString.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SWP.Core.MultiKeyEncryption
|
||||
{
|
||||
public class SecureConnectionString
|
||||
{
|
||||
const string _masterKey = "5AFD74B1C26E87FE6656099E850DC67A";
|
||||
|
||||
public class EncryptedData
|
||||
{
|
||||
public string EncryptedConnectionString { get; set; }
|
||||
public Dictionary<string, string> UserMasterKeys { get; set; } = new();
|
||||
}
|
||||
|
||||
public EncryptedData EncryptConnectionString(string connectionString)
|
||||
{
|
||||
var encryptedConnString = EncryptWithKey(connectionString, _masterKey);
|
||||
var userKeys = new Dictionary<string, string>();
|
||||
|
||||
|
||||
|
||||
return new EncryptedData
|
||||
{
|
||||
EncryptedConnectionString = encryptedConnString,
|
||||
UserMasterKeys = userKeys
|
||||
};
|
||||
}
|
||||
|
||||
public string AddNewUser(string username, string password)
|
||||
{
|
||||
var userKey = DeriveKeyFromPassword(password);
|
||||
var encryptedMasterKey = EncryptWithKey(_masterKey, userKey);
|
||||
return encryptedMasterKey;
|
||||
}
|
||||
|
||||
public string Decrypt(string encryptedConnString, string encryptedMasterKey, string password)
|
||||
{
|
||||
var userKey = DeriveKeyFromPassword(password);
|
||||
var masterKey = DecryptWithKey(encryptedMasterKey, userKey);
|
||||
return DecryptWithKey(encryptedConnString, masterKey);
|
||||
}
|
||||
|
||||
private string DeriveKeyFromPassword(string password)
|
||||
{
|
||||
using var deriveBytes = new Rfc2898DeriveBytes(
|
||||
password,
|
||||
new byte[16], // Fast salt for simpelhed - i produktion bør dette være unikt per bruger
|
||||
10000,
|
||||
HashAlgorithmName.SHA256);
|
||||
|
||||
return Convert.ToBase64String(deriveBytes.GetBytes(32));
|
||||
}
|
||||
|
||||
private string EncryptWithKey(string value, string key)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
var keyBytes = Convert.FromBase64String(key);
|
||||
aes.Key = keyBytes;
|
||||
aes.GenerateIV();
|
||||
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var valueBytes = Encoding.UTF8.GetBytes(value);
|
||||
var encrypted = encryptor.TransformFinalBlock(valueBytes, 0, valueBytes.Length);
|
||||
|
||||
var result = new byte[aes.IV.Length + encrypted.Length];
|
||||
Array.Copy(aes.IV, 0, result, 0, aes.IV.Length);
|
||||
Array.Copy(encrypted, 0, result, aes.IV.Length, encrypted.Length);
|
||||
|
||||
return Convert.ToBase64String(result);
|
||||
}
|
||||
|
||||
private string DecryptWithKey(string encryptedValue, string key)
|
||||
{
|
||||
var encryptedBytes = Convert.FromBase64String(encryptedValue);
|
||||
using var aes = Aes.Create();
|
||||
|
||||
var keyBytes = Convert.FromBase64String(key);
|
||||
aes.Key = keyBytes;
|
||||
|
||||
var iv = new byte[16];
|
||||
Array.Copy(encryptedBytes, 0, iv, 0, iv.Length);
|
||||
aes.IV = iv;
|
||||
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
var decrypted = decryptor.TransformFinalBlock(
|
||||
encryptedBytes,
|
||||
iv.Length,
|
||||
encryptedBytes.Length - iv.Length);
|
||||
|
||||
return Encoding.UTF8.GetString(decrypted);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Core/SWP.Core.csproj
Normal file
28
Core/SWP.Core.csproj
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Autofac" Version="8.1.1"/>
|
||||
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="10.0.0"/>
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0"/>
|
||||
<PackageReference Include="Insight.Database" Version="8.0.1"/>
|
||||
<PackageReference Include="Insight.Database.Providers.PostgreSQL" Version="8.0.1"/>
|
||||
<PackageReference Include="Microsoft.ApplicationInsights" Version="2.22.0"/>
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel" Version="2.22.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.3.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.1"/>
|
||||
<PackageReference Include="npgsql" Version="9.0.2"/>
|
||||
<PackageReference Include="Seq.Api" Version="2024.3.0"/>
|
||||
<PackageReference Include="Sodium.Core" Version="1.3.5"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Configurations\AzureAppConfigurationProvider\"/>
|
||||
<Folder Include="Configurations\PostgresqlConfigurationBuilder\"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
45
Core/SecureTokenizer.cs
Normal file
45
Core/SecureTokenizer.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
namespace SWP.Core
|
||||
{
|
||||
public class SecureTokenizer : ISecureTokenizer
|
||||
{
|
||||
private const int _saltSize = 16; // 128 bit
|
||||
private const int _keySize = 32; // 256 bit
|
||||
private const int _iterations = 100000;
|
||||
|
||||
public string TokenizeText(string word)
|
||||
{
|
||||
using (var algorithm = new System.Security.Cryptography.Rfc2898DeriveBytes(
|
||||
word,
|
||||
_saltSize,
|
||||
_iterations,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256))
|
||||
{
|
||||
var key = Convert.ToBase64String(algorithm.GetBytes(_keySize));
|
||||
var salt = Convert.ToBase64String(algorithm.Salt);
|
||||
|
||||
return $"{_iterations}.{salt}.{key}";
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyToken(string hash, string word)
|
||||
{
|
||||
var parts = hash.Split('.', 3);
|
||||
if (parts.Length != 3)
|
||||
return false;
|
||||
|
||||
var iterations = Convert.ToInt32(parts[0]);
|
||||
var salt = Convert.FromBase64String(parts[1]);
|
||||
var key = Convert.FromBase64String(parts[2]);
|
||||
|
||||
using (var algorithm = new System.Security.Cryptography.Rfc2898DeriveBytes(
|
||||
word,
|
||||
salt,
|
||||
iterations,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256))
|
||||
{
|
||||
var keyToCheck = algorithm.GetBytes(_keySize);
|
||||
return keyToCheck.SequenceEqual(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Core/SeqLogging/SeqBackgroundService.cs
Normal file
86
Core/SeqLogging/SeqBackgroundService.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SWP.Core.Telemetry;
|
||||
|
||||
namespace SWP.Core.SeqLogging
|
||||
{
|
||||
public class SeqBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IMessageChannel<ITelemetry> _messageChannel;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
private readonly SeqLogger<SeqBackgroundService> _seqLogger;
|
||||
|
||||
public SeqBackgroundService(TelemetryClient telemetryClient,
|
||||
IMessageChannel<ITelemetry> messageChannel,
|
||||
SeqLogger<SeqBackgroundService> seqlogger)
|
||||
{
|
||||
_telemetryClient = telemetryClient;
|
||||
_messageChannel = messageChannel;
|
||||
_seqLogger = seqlogger;
|
||||
|
||||
_telemetryClient.TrackTrace("SeqBackgroundService started");
|
||||
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
await foreach (var telemetry in _messageChannel.Reader.ReadAllAsync(stoppingToken))
|
||||
try
|
||||
{
|
||||
switch (telemetry)
|
||||
{
|
||||
case ExceptionTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case TraceTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case DependencyTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case RequestTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case EventTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(telemetry.GetType().Name);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
//_telemetryClient.TrackException(ex); this is disabled for now, we need to think about the channel structure first
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex is not OperationCanceledException)
|
||||
{
|
||||
_telemetryClient.TrackException(ex);
|
||||
throw;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_telemetryClient.TrackTrace("StopAsync called: Service shutdown started");
|
||||
_messageChannel.Dispose();
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
Core/SeqLogging/SeqConfiguration.cs
Normal file
4
Core/SeqLogging/SeqConfiguration.cs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
namespace SWP.Core.SeqLogging
|
||||
{
|
||||
public record SeqConfiguration(string IngestionEndpoint, string ApiKey, string Environment);
|
||||
}
|
||||
28
Core/SeqLogging/SeqHttpClient.cs
Normal file
28
Core/SeqLogging/SeqHttpClient.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace SWP.Core.SeqLogging
|
||||
{
|
||||
public class SeqHttpClient
|
||||
{
|
||||
HttpClient _httpClient;
|
||||
|
||||
public SeqHttpClient(SeqConfiguration seqConfiguration, HttpMessageHandler httpMessageHandler)
|
||||
{
|
||||
_httpClient = new HttpClient(httpMessageHandler)
|
||||
{
|
||||
BaseAddress = new Uri(seqConfiguration.IngestionEndpoint),
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Accept.Clear();
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
if (seqConfiguration.ApiKey != null)
|
||||
_httpClient.DefaultRequestHeaders.Add("X-Seq-ApiKey", seqConfiguration.ApiKey);
|
||||
}
|
||||
|
||||
public SeqHttpClient(SeqConfiguration seqConfiguration) : this(seqConfiguration, new HttpClientHandler()) { }
|
||||
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage httpRequestMessage, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _httpClient.SendAsync(httpRequestMessage, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
236
Core/SeqLogging/SeqLogger.cs
Normal file
236
Core/SeqLogging/SeqLogger.cs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
using System.Text;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
|
||||
namespace SWP.Core.SeqLogging
|
||||
{
|
||||
public class SeqLogger<T>
|
||||
{
|
||||
private readonly SeqHttpClient _httpClient;
|
||||
private readonly SeqConfiguration _configuration;
|
||||
|
||||
public SeqLogger(SeqHttpClient httpClient, SeqConfiguration configuration)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task LogAsync(TraceTelemetry trace, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", trace.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", trace.Message },
|
||||
{ "@l", MapSeverityToLevel(trace.SeverityLevel) },
|
||||
{ "Environment", _configuration.Environment },
|
||||
};
|
||||
|
||||
foreach (var prop in trace.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in trace.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(EventTelemetry evt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", evt.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", evt.Name },
|
||||
{ "@l", "Information" },
|
||||
{ "Environment", _configuration.Environment }
|
||||
};
|
||||
|
||||
foreach (var prop in evt.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in evt.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var metric in evt.Metrics)
|
||||
seqEvent.Add($"metric_{metric.Key}", metric.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(ExceptionTelemetry ex, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", ex.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", ex.Exception.Message },
|
||||
{ "@l", "Error" },
|
||||
{ "@x", FormatExceptionForSeq(ex.Exception) },
|
||||
{ "Environment", _configuration.Environment },
|
||||
{ "ExceptionType", ex.Exception.GetType().Name }
|
||||
};
|
||||
|
||||
foreach (var prop in ex.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in ex.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(DependencyTelemetry dep, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", dep.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", $"Dependency: {dep.Name}" },
|
||||
{ "@l", dep.Success ?? true ? "Information" : "Error" },
|
||||
{ "Environment", _configuration.Environment },
|
||||
{ "DependencyType", dep.Type },
|
||||
{ "Target", dep.Target },
|
||||
{ "Duration", dep.Duration.TotalMilliseconds }
|
||||
};
|
||||
|
||||
foreach (var prop in dep.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in dep.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(RequestTelemetry req, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task LogAsync(
|
||||
Microsoft.ApplicationInsights.Extensibility.IOperationHolder<RequestTelemetry> operationHolder,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var req = operationHolder.Telemetry;
|
||||
|
||||
//https://docs.datalust.co/v2025.1/docs/posting-raw-events
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", req.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", req.Name },
|
||||
{ "@l", req.Success ?? true ? "Information" : "Error" },
|
||||
{ "@sp", req.Id }, //Span id Unique identifier of a span Yes, if the event is a span
|
||||
{
|
||||
"@tr", req.Context.Operation.Id
|
||||
}, //Trace id An identifier that groups all spans and logs that are in the same trace Yes, if the event is a span
|
||||
{
|
||||
"@sk", "Server"
|
||||
}, //Span kind Describes the relationship of the span to others in the trace: Client, Server, Internal, Producer, or Consumer
|
||||
{
|
||||
"@st", req.Timestamp.UtcDateTime.Subtract(req.Duration).ToString("o")
|
||||
}, //Start The start ISO 8601 timestamp of this span Yes, if the event is a span
|
||||
{ "SourceContext", typeof(T).FullName },
|
||||
{ "Url", req.Url },
|
||||
{ "RequestId", req.Id },
|
||||
{ "ItemTypeFlag", req.ItemTypeFlag.ToString() }
|
||||
};
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(req.ResponseCode))
|
||||
if (int.TryParse(req.ResponseCode, out int statusCode))
|
||||
if (Enum.IsDefined(typeof(System.Net.HttpStatusCode), statusCode))
|
||||
seqEvent["StatusCode"] = $"{statusCode} {(System.Net.HttpStatusCode)statusCode}";
|
||||
else
|
||||
seqEvent["StatusCode"] = $"{statusCode} Unknown";
|
||||
|
||||
if (!string.IsNullOrEmpty(req.Context.Operation.ParentId))
|
||||
seqEvent["@ps"] = req.Context.Operation.ParentId;
|
||||
|
||||
if (req.Properties.TryGetValue("httpMethod", out string method))
|
||||
{
|
||||
seqEvent["RequestMethod"] = method;
|
||||
seqEvent["@mt"] = $"{req.Properties["httpMethod"]} {req.Name}";
|
||||
req.Properties.Remove("httpMethod");
|
||||
}
|
||||
|
||||
foreach (var prop in req.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in req.Context.GlobalProperties)
|
||||
seqEvent.Add($"{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SendToSeqAsync(Dictionary<string, object> seqEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
var content = new StringContent(
|
||||
Newtonsoft.Json.JsonConvert.SerializeObject(seqEvent),
|
||||
Encoding.UTF8,
|
||||
"application/vnd.serilog.clef");
|
||||
|
||||
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/ingest/clef")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
var result = await _httpClient.SendAsync(requestMessage, cancellationToken);
|
||||
|
||||
result.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static string MapSeverityToLevel(SeverityLevel? severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
SeverityLevel.Verbose => "Verbose",
|
||||
SeverityLevel.Information => "Information",
|
||||
SeverityLevel.Warning => "Warning",
|
||||
SeverityLevel.Error => "Error",
|
||||
SeverityLevel.Critical => "Fatal",
|
||||
_ => "Information"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatExceptionForSeq(Exception ex)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var exceptionCount = 0;
|
||||
|
||||
void FormatSingleException(Exception currentEx, int depth)
|
||||
{
|
||||
if (depth > 0) sb.AppendLine("\n--- Inner Exception ---");
|
||||
|
||||
sb.AppendLine($"Exception Type: {currentEx.GetType().FullName}");
|
||||
sb.AppendLine($"Message: {currentEx.Message}");
|
||||
sb.AppendLine($"Source: {currentEx.Source}");
|
||||
sb.AppendLine($"HResult: 0x{currentEx.HResult:X8}");
|
||||
sb.AppendLine("Stack Trace:");
|
||||
sb.AppendLine(currentEx.StackTrace?.Trim());
|
||||
|
||||
if (currentEx.Data.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Additional Data:");
|
||||
foreach (var key in currentEx.Data.Keys)
|
||||
sb.AppendLine($" {key}: {currentEx.Data[key]}");
|
||||
}
|
||||
}
|
||||
|
||||
void RecurseExceptions(Exception currentEx, int depth = 0)
|
||||
{
|
||||
if (currentEx is AggregateException aggEx)
|
||||
foreach (var inner in aggEx.InnerExceptions)
|
||||
{
|
||||
RecurseExceptions(inner, depth);
|
||||
depth++;
|
||||
}
|
||||
else if (currentEx.InnerException != null)
|
||||
RecurseExceptions(currentEx.InnerException, depth + 1);
|
||||
|
||||
FormatSingleException(currentEx, depth);
|
||||
exceptionCount++;
|
||||
}
|
||||
|
||||
RecurseExceptions(ex);
|
||||
sb.Insert(0, $"EXCEPTION CHAIN ({exceptionCount} exceptions):\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Core/Telemetry/Enrichers/EnrichWithMetaTelemetry.cs
Normal file
14
Core/Telemetry/Enrichers/EnrichWithMetaTelemetry.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace SWP.Core.Telemetry.Enrichers
|
||||
{
|
||||
public class EnrichWithMetaTelemetry(ITelemetryProcessor next) : ITelemetryProcessor
|
||||
{
|
||||
public void Process(ITelemetry item)
|
||||
{
|
||||
//nothing going on here yet :)
|
||||
next.Process(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Core/Telemetry/IMessageChannel.cs
Normal file
9
Core/Telemetry/IMessageChannel.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using System.Threading.Channels;
|
||||
namespace SWP.Core.Telemetry
|
||||
{
|
||||
public interface IMessageChannel<T> : IDisposable
|
||||
{
|
||||
ChannelWriter<T> Writer { get; }
|
||||
ChannelReader<T> Reader { get; }
|
||||
}
|
||||
}
|
||||
23
Core/Telemetry/MessageChannel.cs
Normal file
23
Core/Telemetry/MessageChannel.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using Microsoft.ApplicationInsights.Channel;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace SWP.Core.Telemetry
|
||||
{
|
||||
public class MessageChannel : IMessageChannel<ITelemetry>
|
||||
{
|
||||
private readonly Channel<ITelemetry> _channel;
|
||||
|
||||
public MessageChannel()
|
||||
{
|
||||
_channel = Channel.CreateUnbounded<ITelemetry>();
|
||||
}
|
||||
|
||||
public ChannelWriter<ITelemetry> Writer => _channel.Writer;
|
||||
public ChannelReader<ITelemetry> Reader => _channel.Reader;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_channel.Writer.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Core/Telemetry/SeqTelemetryChannel.cs
Normal file
36
Core/Telemetry/SeqTelemetryChannel.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
|
||||
namespace SWP.Core.Telemetry
|
||||
{
|
||||
public class SeqTelemetryChannel(IMessageChannel<ITelemetry> messageChannel, TelemetryClient telemetryClient)
|
||||
: InMemoryChannel, ITelemetryChannel
|
||||
{
|
||||
public new void Send(ITelemetry telemetry)
|
||||
{
|
||||
if (telemetry.Context.GlobalProperties.TryGetValue("OmitSeqTelemetryChannel", out var value))
|
||||
if (value == "true")
|
||||
{
|
||||
base.Send(telemetry);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var writeTask = messageChannel.Writer.WriteAsync(telemetry).AsTask();
|
||||
writeTask.ContinueWith(t =>
|
||||
{
|
||||
if (t.Exception != null)
|
||||
throw t.Exception;
|
||||
}, TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
telemetryClient.TrackException(e,
|
||||
new Dictionary<string, string> { { "OmitSeqTelemetryChannel", "true" } });
|
||||
}
|
||||
|
||||
base.Send(telemetry);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Core/Telemetry/TelemetryExtensions.cs
Normal file
12
Core/Telemetry/TelemetryExtensions.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace SWP.Core.Telemetry;
|
||||
|
||||
public static class TelemetryExtensions
|
||||
{
|
||||
public static Dictionary<string, string> Format(this object obj)
|
||||
{
|
||||
return new Dictionary<string, string> { { "Object", JObject.FromObject(obj).ToString() } };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue