Implement InMemory Transport Layer for StellaOps Router
- Added InMemoryTransportOptions class for configuration settings including timeouts and latency. - Developed InMemoryTransportServer class to handle connections, frame processing, and event management. - Created ServiceCollectionExtensions for easy registration of InMemory transport services. - Established project structure and dependencies for InMemory transport library. - Implemented comprehensive unit tests for endpoint discovery, connection management, request/response flow, and streaming capabilities. - Ensured proper handling of cancellation, heartbeat, and hello frames within the transport layer.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
@@ -7,6 +8,33 @@ internal static class CliMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Cli", "1.0.0");
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the CLI is running in sealed/air-gapped mode.
|
||||
/// Per CLI-AIRGAP-56-002: when true, adds "AirGapped-Phase-1" label to all metrics.
|
||||
/// </summary>
|
||||
public static bool IsSealedMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase label for sealed mode telemetry.
|
||||
/// </summary>
|
||||
public static string SealedModePhaseLabel { get; set; } = "AirGapped-Phase-1";
|
||||
|
||||
/// <summary>
|
||||
/// Appends sealed mode tags to the given tags array if in sealed mode.
|
||||
/// </summary>
|
||||
private static KeyValuePair<string, object?>[] WithSealedModeTag(params KeyValuePair<string, object?>[] baseTags)
|
||||
{
|
||||
if (!IsSealedMode)
|
||||
{
|
||||
return baseTags;
|
||||
}
|
||||
|
||||
var tags = new KeyValuePair<string, object?>[baseTags.Length + 1];
|
||||
Array.Copy(baseTags, tags, baseTags.Length);
|
||||
tags[baseTags.Length] = new KeyValuePair<string, object?>("deployment.phase", SealedModePhaseLabel);
|
||||
return tags;
|
||||
}
|
||||
|
||||
private static readonly Counter<long> ScannerDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.download.count");
|
||||
private static readonly Counter<long> ScannerInstallCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.install.count");
|
||||
private static readonly Counter<long> ScanRunCounter = Meter.CreateCounter<long>("stellaops.cli.scan.run.count");
|
||||
@@ -31,34 +59,26 @@ internal static class CliMetrics
|
||||
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
|
||||
|
||||
public static void RecordScannerDownload(string channel, bool fromCache)
|
||||
=> ScannerDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
=> ScannerDownloadCounter.Add(1, WithSealedModeTag(
|
||||
new("channel", channel),
|
||||
new("cache", fromCache ? "hit" : "miss")
|
||||
});
|
||||
new("cache", fromCache ? "hit" : "miss")));
|
||||
|
||||
public static void RecordScannerInstall(string channel)
|
||||
=> ScannerInstallCounter.Add(1, new KeyValuePair<string, object?>[] { new("channel", channel) });
|
||||
=> ScannerInstallCounter.Add(1, WithSealedModeTag(new("channel", channel)));
|
||||
|
||||
public static void RecordScanRun(string runner, int exitCode)
|
||||
=> ScanRunCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
=> ScanRunCounter.Add(1, WithSealedModeTag(
|
||||
new("runner", runner),
|
||||
new("exit_code", exitCode)
|
||||
});
|
||||
new("exit_code", exitCode)));
|
||||
|
||||
public static void RecordOfflineKitDownload(string kind, bool fromCache)
|
||||
=> OfflineKitDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
=> OfflineKitDownloadCounter.Add(1, WithSealedModeTag(
|
||||
new("kind", string.IsNullOrWhiteSpace(kind) ? "unknown" : kind),
|
||||
new("cache", fromCache ? "hit" : "miss")
|
||||
});
|
||||
new("cache", fromCache ? "hit" : "miss")));
|
||||
|
||||
public static void RecordOfflineKitImport(string? status)
|
||||
=> OfflineKitImportCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)
|
||||
});
|
||||
=> OfflineKitImportCounter.Add(1, WithSealedModeTag(
|
||||
new("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)));
|
||||
|
||||
public static void RecordPolicySimulation(string outcome)
|
||||
=> PolicySimulationCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
|
||||
284
src/Cli/StellaOps.Cli/Telemetry/SealedModeTelemetry.cs
Normal file
284
src/Cli/StellaOps.Cli/Telemetry/SealedModeTelemetry.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Sealed mode telemetry configuration for air-gapped environments.
|
||||
/// Per CLI-AIRGAP-56-002: ensures telemetry propagation under sealed mode
|
||||
/// (no remote exporters) while preserving correlation IDs.
|
||||
/// </summary>
|
||||
public sealed class SealedModeTelemetryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether sealed mode is active (no remote telemetry export).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Local directory for telemetry file export (optional).
|
||||
/// If null/empty, telemetry is only logged, not persisted.
|
||||
/// </summary>
|
||||
public string? LocalExportDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum telemetry records to buffer before flushing to file.
|
||||
/// </summary>
|
||||
public int BufferSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Phase label for air-gapped telemetry (e.g., "AirGapped-Phase-1").
|
||||
/// </summary>
|
||||
public string PhaseLabel { get; set; } = "AirGapped-Phase-1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local telemetry sink for sealed/air-gapped mode.
|
||||
/// Preserves correlation IDs and records telemetry locally without remote exports.
|
||||
/// </summary>
|
||||
public sealed class SealedModeTelemetrySink : IDisposable
|
||||
{
|
||||
private readonly ILogger<SealedModeTelemetrySink> _logger;
|
||||
private readonly SealedModeTelemetryOptions _options;
|
||||
private readonly ConcurrentQueue<TelemetryRecord> _buffer;
|
||||
private readonly SemaphoreSlim _flushLock;
|
||||
private bool _disposed;
|
||||
|
||||
public SealedModeTelemetrySink(
|
||||
ILogger<SealedModeTelemetrySink> logger,
|
||||
SealedModeTelemetryOptions options)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_buffer = new ConcurrentQueue<TelemetryRecord>();
|
||||
_flushLock = new SemaphoreSlim(1, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a telemetry event locally with correlation ID preservation.
|
||||
/// </summary>
|
||||
public void Record(
|
||||
string eventName,
|
||||
string? traceId = null,
|
||||
string? spanId = null,
|
||||
IDictionary<string, object?>? attributes = null)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
var record = new TelemetryRecord
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
EventName = eventName,
|
||||
TraceId = traceId ?? activity?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"),
|
||||
SpanId = spanId ?? activity?.SpanId.ToString() ?? Guid.NewGuid().ToString("N")[..16],
|
||||
PhaseLabel = _options.PhaseLabel,
|
||||
Attributes = attributes ?? new Dictionary<string, object?>()
|
||||
};
|
||||
|
||||
_buffer.Enqueue(record);
|
||||
|
||||
_logger.LogDebug(
|
||||
"[{PhaseLabel}] Telemetry recorded: {EventName} trace_id={TraceId} span_id={SpanId}",
|
||||
_options.PhaseLabel,
|
||||
eventName,
|
||||
record.TraceId,
|
||||
record.SpanId);
|
||||
|
||||
// Flush if buffer is full
|
||||
if (_buffer.Count >= _options.BufferSize)
|
||||
{
|
||||
_ = FlushAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a metric event locally.
|
||||
/// </summary>
|
||||
public void RecordMetric(
|
||||
string metricName,
|
||||
double value,
|
||||
IDictionary<string, object?>? tags = null)
|
||||
{
|
||||
var attributes = new Dictionary<string, object?>(tags ?? new Dictionary<string, object?>())
|
||||
{
|
||||
["metric.value"] = value
|
||||
};
|
||||
|
||||
Record($"metric.{metricName}", attributes: attributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes buffered telemetry to local file (if configured).
|
||||
/// </summary>
|
||||
public async Task FlushAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.LocalExportDirectory))
|
||||
{
|
||||
// No file export configured; just drain the queue
|
||||
while (_buffer.TryDequeue(out _)) { }
|
||||
return;
|
||||
}
|
||||
|
||||
await _flushLock.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var records = new List<TelemetryRecord>();
|
||||
while (_buffer.TryDequeue(out var record))
|
||||
{
|
||||
records.Add(record);
|
||||
}
|
||||
|
||||
if (records.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var directory = Path.GetFullPath(_options.LocalExportDirectory);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var fileName = $"telemetry-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.ndjson";
|
||||
var filePath = Path.Combine(directory, fileName);
|
||||
|
||||
await using var writer = new StreamWriter(filePath, append: false);
|
||||
foreach (var record in records)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(record, TelemetryJsonContext.Default.TelemetryRecord);
|
||||
await writer.WriteLineAsync(json).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"[{PhaseLabel}] Flushed {Count} telemetry records to {Path}",
|
||||
_options.PhaseLabel,
|
||||
records.Count,
|
||||
filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"[{PhaseLabel}] Failed to flush telemetry to file",
|
||||
_options.PhaseLabel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_flushLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current correlation context for propagation.
|
||||
/// </summary>
|
||||
public static CorrelationContext GetCorrelationContext()
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
return new CorrelationContext
|
||||
{
|
||||
TraceId = activity?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"),
|
||||
SpanId = activity?.SpanId.ToString() ?? Guid.NewGuid().ToString("N")[..16],
|
||||
TraceFlags = activity?.Recorded == true ? "01" : "00"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a traceparent header value for correlation ID propagation.
|
||||
/// </summary>
|
||||
public static string CreateTraceparent(CorrelationContext? context = null)
|
||||
{
|
||||
context ??= GetCorrelationContext();
|
||||
return $"00-{context.TraceId}-{context.SpanId}-{context.TraceFlags}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Final flush
|
||||
FlushAsync().GetAwaiter().GetResult();
|
||||
_flushLock.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Correlation context for trace propagation in sealed mode.
|
||||
/// </summary>
|
||||
public sealed class CorrelationContext
|
||||
{
|
||||
public required string TraceId { get; init; }
|
||||
public required string SpanId { get; init; }
|
||||
public string TraceFlags { get; init; } = "00";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry record for local storage.
|
||||
/// </summary>
|
||||
public sealed class TelemetryRecord
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public required string EventName { get; init; }
|
||||
public required string TraceId { get; init; }
|
||||
public required string SpanId { get; init; }
|
||||
public string? PhaseLabel { get; init; }
|
||||
public IDictionary<string, object?> Attributes { get; init; } = new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization context for telemetry records.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(TelemetryRecord))]
|
||||
internal partial class TelemetryJsonContext : System.Text.Json.Serialization.JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for sealed mode telemetry registration.
|
||||
/// </summary>
|
||||
public static class SealedModeTelemetryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds sealed mode telemetry services for air-gapped operation.
|
||||
/// Per CLI-AIRGAP-56-002.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSealedModeTelemetry(
|
||||
this IServiceCollection services,
|
||||
Action<SealedModeTelemetryOptions>? configure = null)
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<SealedModeTelemetrySink>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds sealed mode telemetry if running in offline/air-gapped mode.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSealedModeTelemetryIfOffline(
|
||||
this IServiceCollection services,
|
||||
bool isOffline,
|
||||
string? localExportDirectory = null)
|
||||
{
|
||||
if (!isOffline)
|
||||
{
|
||||
return services;
|
||||
}
|
||||
|
||||
return services.AddSealedModeTelemetry(opts =>
|
||||
{
|
||||
opts.Enabled = true;
|
||||
opts.LocalExportDirectory = localExportDirectory;
|
||||
opts.PhaseLabel = "AirGapped-Phase-1";
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user