Initial commit: SWP.Core enterprise framework with multi-tenant architecture, configuration management, security, telemetry and comprehensive test suite

This commit is contained in:
Janus C. H. Knudsen 2025-08-02 22:16:39 +02:00
commit 5275a75502
87 changed files with 6140 additions and 0 deletions

View 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; }
}

View 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";
}

View file

@ -0,0 +1,7 @@
namespace SWP.Core.CommandQueries;
public interface ICommand
{
Guid CorrelationId { get; set; }
Guid TransactionId { get; set; }
}

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

View 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())
};
}
}
}

View 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; }
}
}

View 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 { }
}

View 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; }
}
}

View file

@ -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;
}
}
}

View 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; }
}

View file

@ -0,0 +1,7 @@
namespace SWP.Core.Configurations.SmartConfigProvider;
public interface IConfigurationRepository
{
string ConnectionString { get; set; }
IEnumerable<AppConfiguration> GetActiveConfigurations();
}

View file

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

View file

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

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

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

View file

@ -0,0 +1,8 @@
namespace SWP.Core.Database.ConnectionFactory
{
public interface IDbConnectionFactory
{
System.Data.IDbConnection Create();
System.Data.IDbConnection Create(ConnectionStringParameters connectionStringTemplateParameters);
}
}

View 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();
}
}
}

View 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;
}
}

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

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

View 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>();
}
}
}

View 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;
}
}
}

View 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; }
}
}

View 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
View file

@ -0,0 +1,8 @@
namespace SWP.Core
{
public interface ISecureTokenizer
{
string TokenizeText(string word);
bool VerifyToken(string hash, string word);
}
}

View 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>();
}
}
}

View 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();
}
}
}

View 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; }
}
}

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

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

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

View file

@ -0,0 +1,4 @@
namespace SWP.Core.SeqLogging
{
public record SeqConfiguration(string IngestionEndpoint, string ApiKey, string Environment);
}

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

View 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();
}
}
}

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

View 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; }
}
}

View 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();
}
}
}

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

View 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() } };
}
}