Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Observability;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>
|
||||
/// Garde-fous d'architecture de la Phase 5 (§8/§9/§65/§66/§72) : les packages
|
||||
/// OpenTelemetry sont localisés UNIQUEMENT dans le projet infrastructure dédié
|
||||
/// (Omika.Observability.External), jamais dans Core ni dans le métier (App /
|
||||
/// App.Local / Data). Vérifications par réflexion sur les assemblies référencées.
|
||||
/// </summary>
|
||||
public class ExternalArchitectureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Core_DoesNotReferenceOpenTelemetry()
|
||||
{
|
||||
Assembly core = typeof(OmikaEvent).Assembly;
|
||||
|
||||
Assert.DoesNotContain(core.GetReferencedAssemblies(), a => a.Name!.Contains("OpenTelemetry"));
|
||||
Assert.DoesNotContain(
|
||||
core.GetTypes(),
|
||||
t => (t.FullName ?? "").Contains("OpenTelemetry", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("Otlp", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("SigNoz", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("ClickHouse", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Omika.App")]
|
||||
[InlineData("Omika.App.Local")]
|
||||
[InlineData("Omika.Data")]
|
||||
public void BusinessAssemblies_DoNotReferenceExternalObservabilityInfrastructure(string assemblyName)
|
||||
{
|
||||
Assembly asm = Assembly.Load(assemblyName);
|
||||
|
||||
Assert.DoesNotContain(asm.GetReferencedAssemblies(), a =>
|
||||
a.Name!.Contains("OpenTelemetry")
|
||||
|| a.Name!.Contains("SigNoz")
|
||||
|| a.Name!.Contains("ClickHouse"));
|
||||
|
||||
Assert.DoesNotContain(
|
||||
asm.GetTypes(),
|
||||
t => (t.FullName ?? "").Contains("OpenTelemetry", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("Otlp", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("SigNoz", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("ClickHouse", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("ActivitySource", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("TracerProvider", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackAndLog_Path_ExposesOnlyVoidMethods_NoNetwork()
|
||||
{
|
||||
// Le chemin Track()/Log() du métier reste strictement void (never-throw,
|
||||
// non bloquant, sans réseau) : le réseau OTel est DERRIÈRE le TelemetryWorker.
|
||||
foreach (MethodInfo method in typeof(IOmikaTelemetry).GetMethods()
|
||||
.Concat(typeof(IEventTracker).GetMethods())
|
||||
.Concat(typeof(IDiagnosticLogger).GetMethods()))
|
||||
{
|
||||
Assert.Equal(typeof(void), method.ReturnType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Omika.Core.Observability.External;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>Garde-fous de cardinalité des métriques externes (§36/§37).</summary>
|
||||
public class ExternalCardinalityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("component")]
|
||||
[InlineData("status_class")]
|
||||
[InlineData("method")]
|
||||
[InlineData("route_template")]
|
||||
[InlineData("event_priority")]
|
||||
[InlineData("level")]
|
||||
[InlineData("result")]
|
||||
public void MetricLabels_AllowLowCardinality(string key)
|
||||
{
|
||||
Assert.True(ExternalAttributePolicy.IsAllowedMetricLabel(key));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("traceid")]
|
||||
[InlineData("trace_id")]
|
||||
[InlineData("sessionid")]
|
||||
[InlineData("eventid")]
|
||||
[InlineData("operationid")]
|
||||
[InlineData("userid")]
|
||||
[InlineData("clientid")]
|
||||
[InlineData("collaborateurid")]
|
||||
[InlineData("email")]
|
||||
[InlineData("url")]
|
||||
public void MetricLabels_RejectHighCardinality(string key)
|
||||
{
|
||||
Assert.False(ExternalAttributePolicy.IsAllowedMetricLabel(key));
|
||||
Assert.True(ExternalAttributePolicy.IsForbiddenMetricLabel(key));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForbiddenMetricLabel_DetectsSubstringCaseInsensitive()
|
||||
{
|
||||
Assert.True(ExternalAttributePolicy.IsForbiddenMetricLabel("SessionId"));
|
||||
Assert.True(ExternalAttributePolicy.IsForbiddenMetricLabel("some_OperationId_suffix"));
|
||||
Assert.False(ExternalAttributePolicy.IsForbiddenMetricLabel("component"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogAttributes_RejectForbiddenKeys()
|
||||
{
|
||||
Assert.False(ExternalAttributePolicy.IsAllowedLogAttribute("password"));
|
||||
Assert.False(ExternalAttributePolicy.IsAllowedLogAttribute("Authorization"));
|
||||
Assert.False(ExternalAttributePolicy.IsAllowedLogAttribute("connectionString"));
|
||||
Assert.True(ExternalAttributePolicy.IsAllowedLogAttribute("route_template"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForbiddenValues_AreDetected()
|
||||
{
|
||||
Assert.True(ExternalAttributePolicy.ContainsForbiddenValue("Bearer token abc"));
|
||||
Assert.True(ExternalAttributePolicy.ContainsForbiddenValue("password=123"));
|
||||
Assert.False(ExternalAttributePolicy.ContainsForbiddenValue("une valeur ordinaire"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Omika.Core.Observability.External;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>Disjoncteur du pont externe (§16) : déterministe (horloge injectée).</summary>
|
||||
public class ExternalCircuitBreakerTests
|
||||
{
|
||||
[Fact]
|
||||
public void StartsHealthy()
|
||||
{
|
||||
var circuit = NewCircuit();
|
||||
|
||||
Assert.Equal(ExternalObservabilityState.Healthy, circuit.State);
|
||||
Assert.True(circuit.AllowExport());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpensAfterThresholdFailures()
|
||||
{
|
||||
var circuit = NewCircuit(); // seuil = 3
|
||||
|
||||
circuit.RecordFailure();
|
||||
circuit.RecordFailure();
|
||||
Assert.Equal(ExternalObservabilityState.Degraded, circuit.State);
|
||||
Assert.True(circuit.AllowExport()); // dégradé mais autorisé
|
||||
|
||||
circuit.RecordFailure();
|
||||
Assert.Equal(ExternalObservabilityState.Open, circuit.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropsWhileOpen()
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
var circuit = new ExternalCircuitBreaker(2, TimeSpan.FromSeconds(30), clock.Now);
|
||||
circuit.RecordFailure();
|
||||
circuit.RecordFailure();
|
||||
|
||||
Assert.False(circuit.AllowExport()); // open : drop
|
||||
Assert.False(circuit.AllowExport());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbesAfterCooldown()
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
var circuit = new ExternalCircuitBreaker(1, TimeSpan.FromSeconds(30), clock.Now);
|
||||
circuit.RecordFailure();
|
||||
Assert.False(circuit.AllowExport());
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(31));
|
||||
Assert.True(circuit.AllowExport()); // half-open : autorise UNE sonde
|
||||
Assert.Equal(ExternalObservabilityState.Degraded, circuit.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessClosesCircuit()
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
var circuit = new ExternalCircuitBreaker(1, TimeSpan.FromSeconds(30), clock.Now);
|
||||
circuit.RecordFailure();
|
||||
clock.Advance(TimeSpan.FromSeconds(31));
|
||||
Assert.True(circuit.AllowExport());
|
||||
|
||||
circuit.RecordSuccess();
|
||||
Assert.Equal(ExternalObservabilityState.Healthy, circuit.State);
|
||||
Assert.True(circuit.AllowExport());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeFailureReopensCircuit()
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
var circuit = new ExternalCircuitBreaker(1, TimeSpan.FromSeconds(30), clock.Now);
|
||||
circuit.RecordFailure();
|
||||
clock.Advance(TimeSpan.FromSeconds(31));
|
||||
Assert.True(circuit.AllowExport()); // half-open sonde
|
||||
|
||||
circuit.RecordFailure(); // sonde échoue → ré-ouverture
|
||||
Assert.Equal(ExternalObservabilityState.Open, circuit.State);
|
||||
Assert.False(circuit.AllowExport());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverAllowsNegativeOrZeroCooldown()
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
var circuit = new ExternalCircuitBreaker(1, TimeSpan.Zero, clock.Now);
|
||||
|
||||
circuit.RecordFailure();
|
||||
Assert.False(circuit.AllowExport()); // cooldown ramené à >= 1 s : encore ouvert
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
Assert.True(circuit.AllowExport()); // sonde possible après cooldown positif
|
||||
}
|
||||
|
||||
private static ExternalCircuitBreaker NewCircuit() =>
|
||||
new(3, TimeSpan.FromSeconds(30));
|
||||
|
||||
private sealed class MutableClock
|
||||
{
|
||||
private DateTimeOffset _now = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public DateTimeOffset Now() => _now;
|
||||
|
||||
public void Advance(TimeSpan delta) => _now += delta;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
using Omika.Core.Observability.External;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>
|
||||
/// Compteurs du pont externe (Phase 5) : valeurs initiales à 0, incréments exacts,
|
||||
/// thread-safe (Interlocked), jamais-throw. Compteurs purement mémoire — jamais
|
||||
/// réinjectés dans IOmikaTelemetry (anti-boucle).
|
||||
/// </summary>
|
||||
public class ExternalObservabilityCountersTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllCounters_StartAtZero()
|
||||
{
|
||||
var counters = new ExternalObservabilityCounters();
|
||||
|
||||
Assert.Equal(0, counters.Accepted);
|
||||
Assert.Equal(0, counters.Exported);
|
||||
Assert.Equal(0, counters.DroppedDisabled);
|
||||
Assert.Equal(0, counters.DroppedCircuitOpen);
|
||||
Assert.Equal(0, counters.DroppedSecurity);
|
||||
Assert.Equal(0, counters.DroppedInvalid);
|
||||
Assert.Equal(0, counters.DroppedInternal);
|
||||
Assert.Equal(0, counters.Failure);
|
||||
Assert.Equal(0, counters.CircuitOpened);
|
||||
Assert.Equal(0, counters.Forwarded);
|
||||
Assert.Equal(0, counters.ForwardFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Increments_AreExact()
|
||||
{
|
||||
var counters = new ExternalObservabilityCounters();
|
||||
|
||||
counters.IncrementAccepted();
|
||||
counters.IncrementExported(7);
|
||||
counters.IncrementDroppedDisabled();
|
||||
counters.IncrementDroppedCircuitOpen();
|
||||
counters.IncrementDroppedSecurity();
|
||||
counters.IncrementDroppedInvalid();
|
||||
counters.IncrementDroppedInternal();
|
||||
counters.IncrementFailure();
|
||||
counters.IncrementCircuitOpened();
|
||||
counters.IncrementForwarded(3);
|
||||
counters.IncrementForwardFailure();
|
||||
|
||||
Assert.Equal(1, counters.Accepted);
|
||||
Assert.Equal(7, counters.Exported);
|
||||
Assert.Equal(1, counters.DroppedDisabled);
|
||||
Assert.Equal(1, counters.DroppedCircuitOpen);
|
||||
Assert.Equal(1, counters.DroppedSecurity);
|
||||
Assert.Equal(1, counters.DroppedInvalid);
|
||||
Assert.Equal(1, counters.DroppedInternal);
|
||||
Assert.Equal(1, counters.Failure);
|
||||
Assert.Equal(1, counters.CircuitOpened);
|
||||
Assert.Equal(3, counters.Forwarded);
|
||||
Assert.Equal(1, counters.ForwardFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementExported_AccumulatesByCount()
|
||||
{
|
||||
var counters = new ExternalObservabilityCounters();
|
||||
|
||||
counters.IncrementExported(5);
|
||||
counters.IncrementExported(11);
|
||||
|
||||
Assert.Equal(16, counters.Exported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentIncrements_ProduceExactTotal()
|
||||
{
|
||||
const int threads = 8;
|
||||
const int perThread = 1000;
|
||||
var counters = new ExternalObservabilityCounters();
|
||||
|
||||
Parallel.For(0, threads, _ =>
|
||||
{
|
||||
for (int i = 0; i < perThread; i++)
|
||||
{
|
||||
counters.IncrementAccepted();
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(threads * perThread, counters.Accepted);
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Observability.External;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>Validation de la configuration du pont externe (§74/§75).</summary>
|
||||
public class ExternalObservabilityOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_IsDisabled_AndNotUsable()
|
||||
{
|
||||
var options = new ExternalObservabilityOptions();
|
||||
|
||||
Assert.False(options.Enabled);
|
||||
Assert.False(options.IsUsable);
|
||||
Assert.Empty(options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_IsAlwaysValid()
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = false,
|
||||
OtlpEndpoint = "",
|
||||
ExportTimeoutSeconds = 0,
|
||||
MaxQueueSize = 0
|
||||
};
|
||||
|
||||
Assert.Empty(options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enabled_WithEmptyEndpoint_IsInvalid()
|
||||
{
|
||||
var options = new ExternalObservabilityOptions { Enabled = true, OtlpEndpoint = "" };
|
||||
|
||||
Assert.False(options.IsUsable);
|
||||
Assert.NotEmpty(options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enabled_WithValidHttpEndpoint_IsUsable()
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "http://10.0.0.10:4318"
|
||||
};
|
||||
|
||||
Assert.True(options.IsUsable);
|
||||
Assert.Empty(options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enabled_WithNonHttpEndpoint_IsInvalid()
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "ftp://collector:4318"
|
||||
};
|
||||
|
||||
Assert.False(options.IsUsable);
|
||||
Assert.Contains(options.Validate(), e => e.Contains("http", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(61)]
|
||||
public void ExportTimeoutSeconds_MustBeBetween1And60(int value)
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "http://localhost:4318",
|
||||
ExportTimeoutSeconds = value
|
||||
};
|
||||
|
||||
Assert.False(options.IsUsable);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void NonPositiveBounds_AreInvalid(int value)
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "http://localhost:4318",
|
||||
MaxQueueSize = value
|
||||
};
|
||||
|
||||
Assert.False(options.IsUsable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoInstances_AreIndependent_NoSharedStaticState()
|
||||
{
|
||||
var a = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "http://a:4318",
|
||||
MaxQueueSize = 111
|
||||
};
|
||||
var b = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = false,
|
||||
OtlpEndpoint = "http://b:4318",
|
||||
MaxQueueSize = 222
|
||||
};
|
||||
|
||||
// Chaque instance conserve ses propres valeurs (aucune contamination croisée,
|
||||
// aucun état statique mutable partagé).
|
||||
Assert.True(a.Enabled);
|
||||
Assert.Equal("http://a:4318", a.OtlpEndpoint);
|
||||
Assert.Equal(111, a.MaxQueueSize);
|
||||
|
||||
Assert.False(b.Enabled);
|
||||
Assert.Equal("http://b:4318", b.OtlpEndpoint);
|
||||
Assert.Equal(222, b.MaxQueueSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_HasNoStaticMutableState()
|
||||
{
|
||||
// Garde-fou anti-régression (§13/§28) : la config du pont externe ne doit
|
||||
// être pilotée par AUCUN champ static mutable (DI-friendly, isolable, testable).
|
||||
string[] staticMutable = typeof(ExternalObservabilityOptions)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
|
||||
.Where(f => !f.IsInitOnly && !f.IsLiteral)
|
||||
.Select(f => f.Name)
|
||||
.ToArray();
|
||||
|
||||
Assert.Empty(staticMutable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>Liste blanche et filtrage sécurité du mapping externe (§31/§79/§71).</summary>
|
||||
public class OmikaEventExternalMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapsWhitelistFields_AndExcludesIdentity()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Level = TelemetryLevel.Warning,
|
||||
Priority = TelemetryPriority.P2,
|
||||
Component = "SyncEngine",
|
||||
TraceId = Guid.NewGuid(),
|
||||
SessionId = Guid.NewGuid(),
|
||||
OperationId = Guid.NewGuid(),
|
||||
AppVersion = "1.2.3",
|
||||
Platform = "Android",
|
||||
ErrorCode = "ERR-1",
|
||||
DurationMs = 123,
|
||||
Success = true,
|
||||
DeviceId = "device-abc",
|
||||
ClientId = "client-1",
|
||||
UserId = "user-1",
|
||||
CollaborateurId = "col-1",
|
||||
EntityId = "entity-1"
|
||||
};
|
||||
|
||||
ExternalLogRecord? record = OmikaEventExternalMapper.ToLogRecord(e);
|
||||
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(LogLevel.Warning, record.Level);
|
||||
Assert.Equal(TelemetryEventCodes.MobileAppStarted.Value, record.EventCode);
|
||||
Assert.Equal(e.TraceId, record.TraceId);
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "omika.trace_id");
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "omika.session_id");
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "omika.operation_id");
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "component" && (string?)kv.Value == "SyncEngine");
|
||||
|
||||
// Identité métier : JAMAIS exportée (liste blanche externe).
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "omika.device_id");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "userid");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "clientid");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "collaborateurid");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "entityid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropsForbiddenMetadataKeysAndValues()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Metadata = TelemetryMetadata.Create(
|
||||
("ok_key", "safe-value"),
|
||||
("password", "hunter2"),
|
||||
("Authorization", "Bearer secret"),
|
||||
("connectionString", "Server=..." ),
|
||||
("note", "contains token here"))
|
||||
};
|
||||
|
||||
ExternalLogRecord? record = OmikaEventExternalMapper.ToLogRecord(e);
|
||||
|
||||
Assert.NotNull(record);
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "ok_key");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "password");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "Authorization");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "connectionString");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "note");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsNullForInvalidEvent()
|
||||
{
|
||||
Assert.Null(OmikaEventExternalMapper.ToLogRecord((OmikaEvent?)null));
|
||||
|
||||
// EventCode invalide (défaut) → événement non mappable.
|
||||
var e = new TelemetryEvent();
|
||||
Assert.Null(OmikaEventExternalMapper.ToLogRecord(e));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogLevelMapping_IsDeterministic()
|
||||
{
|
||||
Assert.Equal(LogLevel.Trace, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Trace));
|
||||
Assert.Equal(LogLevel.Debug, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Debug));
|
||||
Assert.Equal(LogLevel.Information, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Info));
|
||||
Assert.Equal(LogLevel.Warning, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Warning));
|
||||
Assert.Equal(LogLevel.Error, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Error));
|
||||
Assert.Equal(LogLevel.Critical, OmikaEventExternalMapper.ToLogLevel(TelemetryLevel.Critical));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsDto_WithWhitelist()
|
||||
{
|
||||
var dto = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-SYNC-001",
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Level = "Error",
|
||||
Priority = "P1",
|
||||
Component = "MobileSync",
|
||||
TraceId = Guid.NewGuid(),
|
||||
SessionId = Guid.NewGuid(),
|
||||
DeviceId = "device-abc",
|
||||
ClientId = "client-1",
|
||||
ErrorCode = "ERR-X",
|
||||
Metadata = new Dictionary<string, object?> { ["k"] = "v", ["password"] = "secret" }
|
||||
};
|
||||
|
||||
ExternalLogRecord? record = OmikaEventExternalMapper.ToLogRecord(dto);
|
||||
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(LogLevel.Error, record.Level);
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "omika.trace_id");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "deviceid");
|
||||
Assert.DoesNotContain(record.Attributes, kv => kv.Key == "password");
|
||||
Assert.Contains(record.Attributes, kv => kv.Key == "k");
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>
|
||||
/// §5C.1C — Le source fichier du RemoteDiagnosticForwarder applique la même
|
||||
/// protection partial-line que le reader du Worker (via JsonlCompleteLineReader).
|
||||
/// </summary>
|
||||
public class RemoteDiagnosticFileForwarderSourceTests
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public void PartialFinalLine_IsNotReturned_AndDoesNotAdvance()
|
||||
{
|
||||
string dir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
string line1 = Serialize(NewRecord(Guid.NewGuid()));
|
||||
string line2 = Serialize(NewRecord(Guid.NewGuid())); // fragment sans newline
|
||||
File.WriteAllText(
|
||||
Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl"),
|
||||
line1 + "\n" + line2,
|
||||
Utf8NoBom);
|
||||
|
||||
var source = new RemoteDiagnosticFileForwarderSource(
|
||||
new RemoteDiagnosticOptions { Directory = dir },
|
||||
"forwarder-checkpoint.json");
|
||||
|
||||
RemoteDiagnosticForwardReadResult read = source.ReadNext(null, 10);
|
||||
|
||||
Assert.Single(read.Records); // seul line1 (complète)
|
||||
Assert.Equal(0, read.NextPosition!.LineIndex); // fragment non checkpointé
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletePoisonLine_IsSkipped_AndCursorAdvances()
|
||||
{
|
||||
string dir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
string line = Serialize(NewRecord(Guid.NewGuid()));
|
||||
File.WriteAllText(
|
||||
Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl"),
|
||||
"{ json invalide\n" + line + "\n",
|
||||
Utf8NoBom);
|
||||
|
||||
var source = new RemoteDiagnosticFileForwarderSource(
|
||||
new RemoteDiagnosticOptions { Directory = dir },
|
||||
"forwarder-checkpoint.json");
|
||||
|
||||
RemoteDiagnosticForwardReadResult read = source.ReadNext(null, 10);
|
||||
|
||||
Assert.Single(read.Records);
|
||||
Assert.Equal(1, read.NextPosition!.LineIndex); // poison (0) sautée, ligne (1)
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
private static RemoteDiagnosticRecord NewRecord(Guid eventId) => new()
|
||||
{
|
||||
ReceivedAtUtc = DateTimeOffset.UtcNow,
|
||||
BatchId = Guid.NewGuid(),
|
||||
IngestionTraceId = Guid.NewGuid().ToString("D"),
|
||||
Event = new DiagnosticEventDto
|
||||
{
|
||||
EventId = eventId,
|
||||
EventCode = "TEST",
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Level = "Info",
|
||||
Priority = "P2"
|
||||
}
|
||||
};
|
||||
|
||||
private static string Serialize(RemoteDiagnosticRecord record) =>
|
||||
JsonSerializer.Serialize(record, JsonOptions);
|
||||
|
||||
private static string CreateTempDir()
|
||||
{
|
||||
string dir = Path.Combine(Path.GetTempPath(), "omika-fwd-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.External;
|
||||
|
||||
/// <summary>RemoteDiagnosticForwarder (§6/§40/§41/§42) : durable, checkpoint, doublons.</summary>
|
||||
public class RemoteDiagnosticForwarderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ForwardsAll_AndAdvancesCheckpoint()
|
||||
{
|
||||
var source = new FakeForwarderSource();
|
||||
source.Add(Guid.NewGuid());
|
||||
source.Add(Guid.NewGuid());
|
||||
source.Add(Guid.NewGuid());
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var forwarder = NewForwarder(source, sink, out _);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.True(progressed);
|
||||
Assert.Equal(3, sink.Received.Count);
|
||||
Assert.Equal(1, source.CommitCount);
|
||||
Assert.NotNull(source.Checkpoint);
|
||||
Assert.Equal(2, source.Checkpoint!.LineIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoesNotAdvanceWhenCircuitOpen()
|
||||
{
|
||||
var source = new FakeForwarderSource();
|
||||
source.Add(Guid.NewGuid());
|
||||
|
||||
var sink = new RecordingSink { Behavior = _ => 0 }; // drop (infra down)
|
||||
var circuit = new ExternalCircuitBreaker(1, TimeSpan.FromSeconds(30));
|
||||
circuit.RecordFailure(); // circuit ouvert
|
||||
|
||||
var forwarder = NewForwarder(source, sink, circuit);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.False(progressed);
|
||||
Assert.Equal(0, source.CommitCount); // pas d'avancement : on réessaiera plus tard
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvancesOverPolicyDroppedBatch()
|
||||
{
|
||||
var source = new FakeForwarderSource();
|
||||
source.Add(Guid.NewGuid());
|
||||
|
||||
// La donnée est "consommée" par politique (sécurité/invalide) sans panne infra :
|
||||
// le forwarder doit avancer pour ne pas rejouer indéfiniment.
|
||||
var sink = new RecordingSink { Behavior = _ => 0 };
|
||||
var forwarder = NewForwarder(source, sink, out _);
|
||||
|
||||
bool progressed = await forwarder.FlushAsync();
|
||||
|
||||
Assert.False(progressed);
|
||||
Assert.Equal(1, source.CommitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AtLeastOnce_ProducesDuplicates_WhenCheckpointLost()
|
||||
{
|
||||
var source = new FakeForwarderSource();
|
||||
Guid eventId = Guid.NewGuid();
|
||||
source.Add(eventId);
|
||||
source.Add(Guid.NewGuid());
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var forwarder = NewForwarder(source, sink, out _);
|
||||
|
||||
await forwarder.FlushAsync();
|
||||
Assert.Equal(2, sink.Received.Count);
|
||||
|
||||
// Checkpoint perdu (redémarrage/panne) : at-least-once → réexpédition (doublons).
|
||||
source.Checkpoint = null;
|
||||
await forwarder.FlushAsync();
|
||||
|
||||
Assert.Equal(4, sink.Received.Count);
|
||||
Assert.Equal(2, sink.Received.Count(r => r.EventId == eventId));
|
||||
}
|
||||
|
||||
private static RemoteDiagnosticForwarder NewForwarder(
|
||||
FakeForwarderSource source,
|
||||
RecordingSink sink,
|
||||
out ExternalCircuitBreaker circuit)
|
||||
{
|
||||
circuit = new ExternalCircuitBreaker(3, TimeSpan.FromSeconds(30));
|
||||
return NewForwarder(source, sink, circuit);
|
||||
}
|
||||
|
||||
private static RemoteDiagnosticForwarder NewForwarder(
|
||||
FakeForwarderSource source,
|
||||
RecordingSink sink,
|
||||
ExternalCircuitBreaker circuit)
|
||||
{
|
||||
var options = new ExternalObservabilityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OtlpEndpoint = "http://localhost:4318",
|
||||
ForwarderBatchSize = 100
|
||||
};
|
||||
|
||||
return new RemoteDiagnosticForwarder(
|
||||
source,
|
||||
sink,
|
||||
options,
|
||||
new ExternalObservabilityCounters(),
|
||||
circuit);
|
||||
}
|
||||
|
||||
private sealed class RecordingSink : IExternalDiagnosticSink
|
||||
{
|
||||
public List<DiagnosticEventDto> Received { get; } = [];
|
||||
|
||||
public Func<IReadOnlyList<DiagnosticEventDto>, int>? Behavior { get; set; }
|
||||
|
||||
public Task<int> ForwardAsync(IReadOnlyList<DiagnosticEventDto> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Received.AddRange(events);
|
||||
return Task.FromResult(Behavior is null ? events.Count : Behavior(events));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeForwarderSource : IRemoteDiagnosticForwarderSource
|
||||
{
|
||||
private readonly List<RemoteDiagnosticRecord> _records = [];
|
||||
|
||||
public RemoteDiagnosticForwarderPosition? Checkpoint { get; set; }
|
||||
|
||||
public int CommitCount { get; private set; }
|
||||
|
||||
public void Add(Guid eventId) => _records.Add(new RemoteDiagnosticRecord
|
||||
{
|
||||
Event = new DiagnosticEventDto { EventId = eventId, EventCode = "OMK-MOB-SYNC-001" }
|
||||
});
|
||||
|
||||
public RemoteDiagnosticForwarderPosition? LoadCheckpoint() => Checkpoint;
|
||||
|
||||
public RemoteDiagnosticForwardReadResult ReadNext(RemoteDiagnosticForwarderPosition? after, int maxCount)
|
||||
{
|
||||
int start = after is null ? 0 : Math.Min(_records.Count, after.LineIndex + 1);
|
||||
int take = Math.Min(maxCount, _records.Count - start);
|
||||
var records = _records.Skip(start).Take(take).ToList();
|
||||
|
||||
return new RemoteDiagnosticForwardReadResult
|
||||
{
|
||||
Records = records,
|
||||
NextPosition = take == 0
|
||||
? null
|
||||
: new RemoteDiagnosticForwarderPosition { FileName = "mem", LineIndex = start + take - 1 },
|
||||
ReachedEnd = start + take >= _records.Count
|
||||
};
|
||||
}
|
||||
|
||||
public void Commit(RemoteDiagnosticForwarderPosition position)
|
||||
{
|
||||
Checkpoint = position;
|
||||
CommitCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user