chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
// CvdiagEmitter.cs — the shared .NET CVDIAG emitter (plan unit L0-D; spec §6/§7).
|
||||
//
|
||||
// Mirrors the canonical TS `emit.ts` contract for the .NET integrations:
|
||||
// • Tier resolution from the deployment environment, with the .NET-specific
|
||||
// precedence SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT
|
||||
// (the last is the .NET analogue of TS's NODE_ENV).
|
||||
// • Fail-closed DEBUG: the constructor throws (startup assertion — the ONE
|
||||
// place the emitter is permitted to throw) when DEBUG is requested in a
|
||||
// production env, when no env resolves (unknown == production), or when no
|
||||
// CVDIAG_DEBUG_ALLOW_LIST is provided.
|
||||
// • §6 tier matrix: a boundary is emitted only if included at the current
|
||||
// tier; `cvdiag.*` accounting boundaries are always emitted.
|
||||
// • DEBUG auto-off: after 10 minutes OR 10k events, DEBUG disarms and falls
|
||||
// back to default-tier inclusion.
|
||||
// • Closed-world metadata filter: unknown metadata keys for the boundary are
|
||||
// dropped and `_metadata_dropped` is stamped.
|
||||
// • Edge-header allow/deny filter (9 allow / 12 deny, exact-match, deny wins,
|
||||
// case-insensitive, no cf-ip* wildcard).
|
||||
// • Per-event byte cap by tier; over-budget envelopes are trimmed +
|
||||
// `_truncated` stamped.
|
||||
// • EmitEvent → single-line JSON to stdout, PLUS a background fire-and-forget
|
||||
// PocketBase write (HttpClient, ≤1s timeout) that never blocks or throws
|
||||
// into the observed boundary.
|
||||
//
|
||||
// Pure instrumentation: outside the constructor's startup guard, a CVDIAG
|
||||
// failure must NEVER throw into the caller (spec §7). All hot-path errors
|
||||
// degrade to a stderr CVDIAG-tagged warning.
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Copilotkit.Showcase.Cvdiag;
|
||||
|
||||
/// <summary>Resolved verbosity tier (cumulative; spec §6).</summary>
|
||||
public enum CvdiagTier { Default, Verbose, Debug }
|
||||
|
||||
/// <summary>Construction options for <see cref="CvdiagEmitter"/>.</summary>
|
||||
public sealed class CvdiagEmitterOptions
|
||||
{
|
||||
/// <summary>Force DEBUG tier (subject to the fail-closed prod guard).</summary>
|
||||
public bool Debug { get; init; }
|
||||
/// <summary>Force VERBOSE tier (DEBUG wins if both set).</summary>
|
||||
public bool Verbose { get; init; }
|
||||
/// <summary>Environment bag; defaults to the process environment.</summary>
|
||||
public IReadOnlyDictionary<string, string?>? Env { get; init; }
|
||||
/// <summary>Owning layer for default envelope fields.</summary>
|
||||
public CvdiagLayer Layer { get; init; } = CvdiagLayer.Backend;
|
||||
/// <summary>PocketBase ingest URL; when null, no background write is attempted.</summary>
|
||||
public string? PbWriteUrl { get; init; }
|
||||
/// <summary>Injected HttpClient (tests); defaults to a shared 1s-timeout client.</summary>
|
||||
public HttpClient? HttpClient { get; init; }
|
||||
/// <summary>Emit the single-line JSON to stdout (default true).</summary>
|
||||
public bool WriteToStdout { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>Arguments for a single <see cref="CvdiagEmitter.Emit"/> call.</summary>
|
||||
public sealed class CvdiagEmitArgs
|
||||
{
|
||||
public required CvdiagLayer Layer { get; init; }
|
||||
public required CvdiagBoundary Boundary { get; init; }
|
||||
public required string Slug { get; init; }
|
||||
public required string Demo { get; init; }
|
||||
public required CvdiagOutcome Outcome { get; init; }
|
||||
public EdgeHeaders? EdgeHeaders { get; init; }
|
||||
public Dictionary<string, object?>? Metadata { get; init; }
|
||||
public long? DurationMs { get; init; }
|
||||
public string? ParentSpanId { get; init; }
|
||||
/// <summary>Override test_id (e.g. probe.start mints one and threads it).</summary>
|
||||
public string? TestId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CvdiagEmitter
|
||||
{
|
||||
// §6 hard bounds + §7 byte caps (mirror emit.ts constants).
|
||||
private const long DebugMaxWallclockMs = 10 * 60 * 1000;
|
||||
private const int DebugMaxEvents = 10_000;
|
||||
private static readonly IReadOnlyDictionary<CvdiagTier, int> ByteCapByTier =
|
||||
new Dictionary<CvdiagTier, int>
|
||||
{
|
||||
[CvdiagTier.Default] = 2 * 1024,
|
||||
[CvdiagTier.Verbose] = 4 * 1024,
|
||||
[CvdiagTier.Debug] = 16 * 1024,
|
||||
};
|
||||
|
||||
private const string AccountingPrefix = "cvdiag.";
|
||||
|
||||
// The 12-name DENY list (spec §5). Exact-match, lowercase, deny wins; the
|
||||
// cf-ip* family is blocked by these explicit entries, NOT a wildcard.
|
||||
private static readonly HashSet<string> DenyList = new(StringComparer.Ordinal)
|
||||
{
|
||||
"cf-ipcountry", "cf-connecting-ip", "cf-ipcity", "cf-iplatitude",
|
||||
"cf-iplongitude", "cf-iptimezone", "cf-visitor", "cf-worker",
|
||||
"true-client-ip", "x-forwarded-for", "x-real-ip", "forwarded",
|
||||
};
|
||||
private static readonly HashSet<string> AllowList =
|
||||
new(CvdiagSchema.EdgeHeaderKeys, StringComparer.Ordinal);
|
||||
|
||||
// §6 tier matrix: per data-plane boundary, included-at-tier flags.
|
||||
private static readonly IReadOnlyDictionary<string, (bool Default, bool Verbose, bool Debug)> TierMatrix =
|
||||
new Dictionary<string, (bool, bool, bool)>
|
||||
{
|
||||
["probe.start"] = (false, true, true),
|
||||
["probe.navigate.complete"] = (false, true, true),
|
||||
["probe.message.send"] = (true, true, true),
|
||||
["probe.dom.container.mount"] = (true, true, true),
|
||||
["probe.dom.firsttoken"] = (true, true, true),
|
||||
["probe.dom.alternate_content"] = (true, true, true),
|
||||
["probe.sse.event"] = (false, true, true),
|
||||
["probe.sse.aborted"] = (true, true, true),
|
||||
["probe.network.error"] = (true, true, true),
|
||||
["probe.network.response"] = (true, true, true),
|
||||
["probe.console.error"] = (true, true, true),
|
||||
["probe.exit"] = (true, true, true),
|
||||
["backend.request.ingress"] = (false, true, true),
|
||||
["backend.agent.enter"] = (true, true, true),
|
||||
["backend.llm.call.start"] = (false, true, true),
|
||||
["backend.llm.call.heartbeat"] = (false, true, true),
|
||||
["backend.llm.call.response"] = (false, true, true),
|
||||
["backend.sse.first_byte"] = (false, true, true),
|
||||
["backend.sse.event"] = (false, false, true),
|
||||
["backend.sse.aborted"] = (true, true, true),
|
||||
["backend.agent.exit"] = (true, true, true),
|
||||
["backend.response.complete"] = (true, true, true),
|
||||
["backend.error.caught"] = (true, true, true),
|
||||
["aimock.request.ingress"] = (false, true, true),
|
||||
["aimock.match.decision"] = (false, true, true),
|
||||
["aimock.response.start"] = (false, true, true),
|
||||
["aimock.sse.chunk"] = (false, false, true),
|
||||
["aimock.response.aborted"] = (true, true, true),
|
||||
["aimock.response.complete"] = (true, true, true),
|
||||
};
|
||||
|
||||
private static readonly HttpClient SharedHttpClient =
|
||||
new() { Timeout = TimeSpan.FromSeconds(1) };
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string?> _env;
|
||||
private readonly CvdiagLayer _defaultLayer;
|
||||
private readonly string? _pbWriteUrl;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _writeToStdout;
|
||||
private readonly long _debugDeadlineMs;
|
||||
private readonly Stopwatch _mono = Stopwatch.StartNew();
|
||||
|
||||
private long _debugEventCount;
|
||||
private bool _debugDisarmed;
|
||||
|
||||
public CvdiagTier Tier { get; }
|
||||
|
||||
public CvdiagEmitter(CvdiagEmitterOptions? options = null)
|
||||
{
|
||||
options ??= new CvdiagEmitterOptions();
|
||||
_env = options.Env ?? ReadProcessEnv();
|
||||
_defaultLayer = options.Layer;
|
||||
_pbWriteUrl = options.PbWriteUrl;
|
||||
_httpClient = options.HttpClient ?? SharedHttpClient;
|
||||
_writeToStdout = options.WriteToStdout;
|
||||
|
||||
var wantsDebug = options.Debug || _env.GetValueOrDefault("CVDIAG_DEBUG") == "1";
|
||||
var wantsVerbose = options.Verbose || _env.GetValueOrDefault("CVDIAG_VERBOSE") == "1";
|
||||
|
||||
if (wantsDebug)
|
||||
{
|
||||
AssertDebugAllowed();
|
||||
Tier = CvdiagTier.Debug;
|
||||
_debugDeadlineMs = NowMs() + DebugMaxWallclockMs;
|
||||
}
|
||||
else
|
||||
{
|
||||
Tier = wantsVerbose ? CvdiagTier.Verbose : CvdiagTier.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the deployment-environment label (spec §6 production detection):
|
||||
/// SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT.
|
||||
/// Returns the lowercase label, or null if none resolves.
|
||||
/// </summary>
|
||||
public static string? ResolveEnvLabel(IReadOnlyDictionary<string, string?> env)
|
||||
{
|
||||
var raw = env.GetValueOrDefault("SHOWCASE_ENV")
|
||||
?? env.GetValueOrDefault("RAILWAY_ENVIRONMENT_NAME")
|
||||
?? env.GetValueOrDefault("ASPNETCORE_ENVIRONMENT");
|
||||
return string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DEBUG startup assertion (spec §6 hard bounds). Throws (fail-closed) when
|
||||
/// the env is production, unresolved (unknown == production), or no
|
||||
/// allow-list is provided. The ONE place the emitter may throw.
|
||||
/// </summary>
|
||||
private void AssertDebugAllowed()
|
||||
{
|
||||
var label = ResolveEnvLabel(_env);
|
||||
if (label is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CVDIAG_DEBUG refused: deployment environment is unresolved " +
|
||||
"(SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT all unset); " +
|
||||
"fail-closed treats unknown env as production.");
|
||||
}
|
||||
if (label == "production")
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CVDIAG_DEBUG refused: deployment environment is production.");
|
||||
}
|
||||
var allowList = _env.GetValueOrDefault("CVDIAG_DEBUG_ALLOW_LIST");
|
||||
if (string.IsNullOrWhiteSpace(allowList))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CVDIAG_DEBUG refused: CVDIAG_DEBUG_ALLOW_LIST is required " +
|
||||
"(comma-separated slug list) before DEBUG may start.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True iff the boundary is included at the current tier.</summary>
|
||||
public bool ShouldEmit(CvdiagBoundary boundary)
|
||||
{
|
||||
var wire = CvdiagSchema.WireValue(boundary);
|
||||
if (wire.StartsWith(AccountingPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return true; // accounting events always emit
|
||||
}
|
||||
if (Tier == CvdiagTier.Debug && IsDebugExpired())
|
||||
{
|
||||
return TierMatrix.TryGetValue(wire, out var fb) && fb.Default;
|
||||
}
|
||||
if (!TierMatrix.TryGetValue(wire, out var row)) return false;
|
||||
return Tier switch
|
||||
{
|
||||
CvdiagTier.Default => row.Default,
|
||||
CvdiagTier.Verbose => row.Verbose,
|
||||
CvdiagTier.Debug => row.Debug,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsDebugExpired()
|
||||
{
|
||||
if (_debugDisarmed) return true;
|
||||
if (NowMs() >= _debugDeadlineMs || _debugEventCount >= DebugMaxEvents)
|
||||
{
|
||||
_debugDisarmed = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit one event: tier-filter, mint ids, closed-world metadata filter,
|
||||
/// byte-cap, then EmitEvent (stdout + background PB write). Returns the
|
||||
/// built envelope, or null when filtered out / on failure. Never throws.
|
||||
/// </summary>
|
||||
public CvdiagEnvelope? Emit(CvdiagEmitArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ShouldEmit(args.Boundary)) return null;
|
||||
if (Tier == CvdiagTier.Debug) _debugEventCount++;
|
||||
|
||||
var wire = CvdiagSchema.WireValue(args.Boundary);
|
||||
var isDataPlane = !wire.StartsWith(AccountingPrefix, StringComparison.Ordinal);
|
||||
|
||||
var metadata = new Dictionary<string, object?>();
|
||||
var metadataDropped = false;
|
||||
if (isDataPlane)
|
||||
{
|
||||
(metadata, metadataDropped) = FilterMetadata(wire, args.Metadata);
|
||||
}
|
||||
else if (args.Metadata is not null)
|
||||
{
|
||||
// Accounting events ride their payload verbatim (trusted internal).
|
||||
metadata = new Dictionary<string, object?>(args.Metadata);
|
||||
}
|
||||
|
||||
var testId = args.TestId ?? MintTestId();
|
||||
var envelope = new CvdiagEnvelope
|
||||
{
|
||||
SchemaVersion = CvdiagSchema.SchemaVersion,
|
||||
TestId = testId,
|
||||
TraceId = testId,
|
||||
SpanId = MintSpanId(),
|
||||
ParentSpanId = args.ParentSpanId,
|
||||
Layer = args.Layer,
|
||||
Boundary = args.Boundary,
|
||||
Slug = args.Slug,
|
||||
Demo = args.Demo,
|
||||
Ts = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
|
||||
MonoNs = MonoNs(),
|
||||
DurationMs = args.DurationMs,
|
||||
Outcome = args.Outcome,
|
||||
EdgeHeaders = args.EdgeHeaders ?? new EdgeHeaders(),
|
||||
Metadata = metadata,
|
||||
MetadataDropped = metadataDropped,
|
||||
};
|
||||
|
||||
ApplyByteCap(envelope);
|
||||
EmitEvent(envelope);
|
||||
return envelope;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"CVDIAG emit failed boundary={args.Boundary} error={ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EmitEvent: write the single-line JSON to stdout AND kick off a
|
||||
/// fire-and-forget background PB write (≤1s). Never blocks the caller;
|
||||
/// background failures are swallowed to a stderr warning.
|
||||
/// </summary>
|
||||
public void EmitEvent(CvdiagEnvelope envelope)
|
||||
{
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = JsonSerializer.Serialize(envelope, CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"CVDIAG serialize failed error={ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_writeToStdout) Console.Out.WriteLine(json);
|
||||
|
||||
if (string.IsNullOrEmpty(_pbWriteUrl)) return;
|
||||
// Fire-and-forget: do not await, do not throw into the boundary.
|
||||
_ = BackgroundWriteAsync(json);
|
||||
}
|
||||
|
||||
private async Task BackgroundWriteAsync(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||||
await _httpClient.PostAsync(_pbWriteUrl, content, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"CVDIAG pb-write failed error={ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed-world metadata filter: keep only the boundary's schema keys; drop
|
||||
/// the rest and flag <c>_metadata_dropped</c> (spec §6). If the boundary has
|
||||
/// no key set (shouldn't happen for data-plane), keep nothing and flag drop.
|
||||
/// </summary>
|
||||
private static (Dictionary<string, object?> Metadata, bool Dropped) FilterMetadata(
|
||||
string wireBoundary, Dictionary<string, object?>? raw)
|
||||
{
|
||||
var result = new Dictionary<string, object?>();
|
||||
if (raw is null || raw.Count == 0) return (result, false);
|
||||
if (!CvdiagSchema.BoundaryMetadataKeys.TryGetValue(wireBoundary, out var allowed))
|
||||
{
|
||||
return (result, true);
|
||||
}
|
||||
var allowedSet = new HashSet<string>(allowed, StringComparer.Ordinal);
|
||||
var dropped = false;
|
||||
foreach (var (key, value) in raw)
|
||||
{
|
||||
if (allowedSet.Contains(key)) result[key] = value;
|
||||
else dropped = true;
|
||||
}
|
||||
return (result, dropped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter a raw header bag to the closed 9-key <see cref="EdgeHeaders"/>:
|
||||
/// deny-list rejected first (deny wins, case-insensitive); non-allow keys
|
||||
/// dropped; every result carries all 9 keys (absent → null). No cf-ip*
|
||||
/// wildcard — only exact deny-list entries.
|
||||
/// </summary>
|
||||
public static EdgeHeaders FilterEdgeHeaders(IReadOnlyDictionary<string, string?> raw)
|
||||
{
|
||||
var kept = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var (rawKey, rawValue) in raw)
|
||||
{
|
||||
var key = rawKey.ToLowerInvariant();
|
||||
if (DenyList.Contains(key)) continue; // deny wins
|
||||
if (!AllowList.Contains(key)) continue; // closed-world
|
||||
kept[key] = rawValue;
|
||||
}
|
||||
string? Get(string k) => kept.TryGetValue(k, out var v) ? v : null;
|
||||
return new EdgeHeaders
|
||||
{
|
||||
CfRay = Get("cf-ray"),
|
||||
CfMitigated = Get("cf-mitigated"),
|
||||
CfCacheStatus = Get("cf-cache-status"),
|
||||
XRailwayEdge = Get("x-railway-edge"),
|
||||
XRailwayRequestId = Get("x-railway-request-id"),
|
||||
XHikariTrace = Get("x-hikari-trace"),
|
||||
RetryAfter = Get("retry-after"),
|
||||
Via = Get("via"),
|
||||
Server = Get("server"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trim over-budget envelopes to the tier byte cap and stamp
|
||||
/// <c>_truncated</c> (spec §7). Metadata string values are trimmed first.
|
||||
/// </summary>
|
||||
private void ApplyByteCap(CvdiagEnvelope envelope)
|
||||
{
|
||||
var cap = ByteCapByTier[Tier];
|
||||
if (SerializedSize(envelope) <= cap) return;
|
||||
envelope.Truncated = true;
|
||||
foreach (var key in new List<string>(envelope.Metadata.Keys))
|
||||
{
|
||||
if (SerializedSize(envelope) <= cap) break;
|
||||
var value = envelope.Metadata[key];
|
||||
if (value is string s && s.Length > 64)
|
||||
{
|
||||
envelope.Metadata[key] = s[..61] + "...";
|
||||
}
|
||||
else if (value is not null && value is not (int or long or double or bool))
|
||||
{
|
||||
envelope.Metadata[key] = "[truncated]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int SerializedSize(CvdiagEnvelope envelope)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(envelope, CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
return Encoding.UTF8.GetByteCount(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mint a UUIDv7 (RFC 9562): 48-bit Unix-ms timestamp, version nibble 7,
|
||||
/// variant bits 10. Lowercase hyphenated. Matches the TS mintTestId().
|
||||
/// </summary>
|
||||
public static string MintTestId(long? nowMs = null)
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
var ts = (ulong)(nowMs ?? NowMs());
|
||||
bytes[0] = (byte)((ts >> 40) & 0xff);
|
||||
bytes[1] = (byte)((ts >> 32) & 0xff);
|
||||
bytes[2] = (byte)((ts >> 24) & 0xff);
|
||||
bytes[3] = (byte)((ts >> 16) & 0xff);
|
||||
bytes[4] = (byte)((ts >> 8) & 0xff);
|
||||
bytes[5] = (byte)(ts & 0xff);
|
||||
bytes[6] = (byte)((bytes[6] & 0x0f) | 0x70); // version 7
|
||||
bytes[8] = (byte)((bytes[8] & 0x3f) | 0x80); // variant 10
|
||||
var hex = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
return $"{hex[..8]}-{hex[8..12]}-{hex[12..16]}-{hex[16..20]}-{hex[20..32]}";
|
||||
}
|
||||
|
||||
/// <summary>Mint a 16-hex span id (8 random bytes).</summary>
|
||||
public static string MintSpanId()
|
||||
{
|
||||
var bytes = new byte[8];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private long MonoNs() => (long)(_mono.Elapsed.TotalMilliseconds * 1e6);
|
||||
|
||||
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
private static IReadOnlyDictionary<string, string?> ReadProcessEnv()
|
||||
{
|
||||
var dict = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (System.Collections.DictionaryEntry e in Environment.GetEnvironmentVariables())
|
||||
{
|
||||
if (e.Key is string k) dict[k] = e.Value as string;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// <auto-generated>
|
||||
// CvdiagSchema.cs — C# binding for the CVDIAG flap-observability envelope.
|
||||
// CODEGEN'd from the single IR `showcase/harness/src/cvdiag/schema.json`
|
||||
// ($id https://copilotkit.ai/schemas/cvdiag/v1.json, schema_version 1).
|
||||
// Plan unit L0-D; spec §5. Mirrors the canonical TS `schema.ts` / Pydantic /
|
||||
// Java bindings so all five languages share one wire contract.
|
||||
//
|
||||
// Source of truth: schema.json. To regenerate, re-run the .NET codegen step
|
||||
// (see L1-F build-context note in the slot report) — DO NOT hand-edit the
|
||||
// enum members / record fields below; edit schema.ts (→ schema.json) and
|
||||
// re-emit. The 3 enums, the EdgeHeaders record, the CvdiagEnvelope record,
|
||||
// the 29 per-boundary metadata records, and the closed key tables below are
|
||||
// derived 1:1 from schema.json `$defs`.
|
||||
//
|
||||
// System.Text.Json source-generation: `CvdiagJsonContext` carries the
|
||||
// [JsonSerializable] attributes so serialization is AOT/trim-safe and never
|
||||
// uses reflection-based serializers at runtime.
|
||||
// </auto-generated>
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Copilotkit.Showcase.Cvdiag;
|
||||
|
||||
/// <summary>Emitting layer (schema.json `$defs.layers`).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagLayer>))]
|
||||
public enum CvdiagLayer
|
||||
{
|
||||
[JsonStringEnumMemberName("probe")] Probe,
|
||||
[JsonStringEnumMemberName("backend")] Backend,
|
||||
[JsonStringEnumMemberName("aimock")] Aimock,
|
||||
}
|
||||
|
||||
/// <summary>Event outcome (schema.json `$defs.outcomes`).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagOutcome>))]
|
||||
public enum CvdiagOutcome
|
||||
{
|
||||
[JsonStringEnumMemberName("ok")] Ok,
|
||||
[JsonStringEnumMemberName("err")] Err,
|
||||
[JsonStringEnumMemberName("timeout")] Timeout,
|
||||
[JsonStringEnumMemberName("info")] Info,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed boundary enum (schema.json `$defs.boundaries`, 33 members). The
|
||||
/// dotted wire values (e.g. "backend.response.complete") are carried by the
|
||||
/// [JsonStringEnumMemberName] attributes; the C# member names are the
|
||||
/// PascalCase'd, dot-stripped equivalents.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagBoundary>))]
|
||||
public enum CvdiagBoundary
|
||||
{
|
||||
[JsonStringEnumMemberName("probe.start")] ProbeStart,
|
||||
[JsonStringEnumMemberName("probe.navigate.complete")] ProbeNavigateComplete,
|
||||
[JsonStringEnumMemberName("probe.message.send")] ProbeMessageSend,
|
||||
[JsonStringEnumMemberName("probe.dom.container.mount")] ProbeDomContainerMount,
|
||||
[JsonStringEnumMemberName("probe.dom.firsttoken")] ProbeDomFirsttoken,
|
||||
[JsonStringEnumMemberName("probe.dom.alternate_content")] ProbeDomAlternateContent,
|
||||
[JsonStringEnumMemberName("probe.sse.event")] ProbeSseEvent,
|
||||
[JsonStringEnumMemberName("probe.sse.aborted")] ProbeSseAborted,
|
||||
[JsonStringEnumMemberName("probe.network.error")] ProbeNetworkError,
|
||||
[JsonStringEnumMemberName("probe.network.response")] ProbeNetworkResponse,
|
||||
[JsonStringEnumMemberName("probe.console.error")] ProbeConsoleError,
|
||||
[JsonStringEnumMemberName("probe.exit")] ProbeExit,
|
||||
[JsonStringEnumMemberName("backend.request.ingress")] BackendRequestIngress,
|
||||
[JsonStringEnumMemberName("backend.agent.enter")] BackendAgentEnter,
|
||||
[JsonStringEnumMemberName("backend.llm.call.start")] BackendLlmCallStart,
|
||||
[JsonStringEnumMemberName("backend.llm.call.heartbeat")] BackendLlmCallHeartbeat,
|
||||
[JsonStringEnumMemberName("backend.llm.call.response")] BackendLlmCallResponse,
|
||||
[JsonStringEnumMemberName("backend.sse.first_byte")] BackendSseFirstByte,
|
||||
[JsonStringEnumMemberName("backend.sse.event")] BackendSseEvent,
|
||||
[JsonStringEnumMemberName("backend.sse.aborted")] BackendSseAborted,
|
||||
[JsonStringEnumMemberName("backend.agent.exit")] BackendAgentExit,
|
||||
[JsonStringEnumMemberName("backend.response.complete")] BackendResponseComplete,
|
||||
[JsonStringEnumMemberName("backend.error.caught")] BackendErrorCaught,
|
||||
[JsonStringEnumMemberName("aimock.request.ingress")] AimockRequestIngress,
|
||||
[JsonStringEnumMemberName("aimock.match.decision")] AimockMatchDecision,
|
||||
[JsonStringEnumMemberName("aimock.response.start")] AimockResponseStart,
|
||||
[JsonStringEnumMemberName("aimock.sse.chunk")] AimockSseChunk,
|
||||
[JsonStringEnumMemberName("aimock.response.aborted")] AimockResponseAborted,
|
||||
[JsonStringEnumMemberName("aimock.response.complete")] AimockResponseComplete,
|
||||
[JsonStringEnumMemberName("cvdiag.purge_audit")] CvdiagPurgeAudit,
|
||||
[JsonStringEnumMemberName("cvdiag.collision_detected")] CvdiagCollisionDetected,
|
||||
[JsonStringEnumMemberName("cvdiag.queue_dropped")] CvdiagQueueDropped,
|
||||
[JsonStringEnumMemberName("cvdiag.metadata_dropped")] CvdiagMetadataDropped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The closed 9-key edge-header bag (schema.json `edge_headers`). Every field
|
||||
/// is always present on the wire; an absent header serializes as null. The
|
||||
/// hyphenated wire names are carried by [JsonPropertyName].
|
||||
/// </summary>
|
||||
public sealed record EdgeHeaders
|
||||
{
|
||||
[JsonPropertyName("cf-ray")] public string? CfRay { get; init; }
|
||||
[JsonPropertyName("cf-mitigated")] public string? CfMitigated { get; init; }
|
||||
[JsonPropertyName("cf-cache-status")] public string? CfCacheStatus { get; init; }
|
||||
[JsonPropertyName("x-railway-edge")] public string? XRailwayEdge { get; init; }
|
||||
[JsonPropertyName("x-railway-request-id")] public string? XRailwayRequestId { get; init; }
|
||||
[JsonPropertyName("x-hikari-trace")] public string? XHikariTrace { get; init; }
|
||||
[JsonPropertyName("retry-after")] public string? RetryAfter { get; init; }
|
||||
[JsonPropertyName("via")] public string? Via { get; init; }
|
||||
[JsonPropertyName("server")] public string? Server { get; init; }
|
||||
|
||||
/// <summary>Always 9 — the closed allow-list cardinality.</summary>
|
||||
public int KeyCount() => 9;
|
||||
|
||||
/// <summary>All 9 field values (for deny-leakage assertions/inspection).</summary>
|
||||
public IReadOnlyList<string?> AllValues() => new[]
|
||||
{
|
||||
CfRay, CfMitigated, CfCacheStatus, XRailwayEdge, XRailwayRequestId,
|
||||
XHikariTrace, RetryAfter, Via, Server,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The CVDIAG envelope (schema.json root). Field/property names map to the
|
||||
/// snake_case wire keys via [JsonPropertyName]; the two optional `_`-prefixed
|
||||
/// stamp flags are written only when true (DefaultIgnoreCondition handles the
|
||||
/// false/null case via the source-gen context options).
|
||||
/// </summary>
|
||||
public sealed record CvdiagEnvelope
|
||||
{
|
||||
[JsonPropertyName("schema_version")] public int SchemaVersion { get; init; } = CvdiagSchema.SchemaVersion;
|
||||
[JsonPropertyName("test_id")] public string TestId { get; init; } = "";
|
||||
[JsonPropertyName("trace_id")] public string TraceId { get; init; } = "";
|
||||
[JsonPropertyName("span_id")] public string SpanId { get; init; } = "";
|
||||
[JsonPropertyName("parent_span_id")] public string? ParentSpanId { get; init; }
|
||||
[JsonPropertyName("layer")] public CvdiagLayer Layer { get; init; }
|
||||
[JsonPropertyName("boundary")] public CvdiagBoundary Boundary { get; init; }
|
||||
[JsonPropertyName("slug")] public string Slug { get; init; } = "";
|
||||
[JsonPropertyName("demo")] public string Demo { get; init; } = "";
|
||||
[JsonPropertyName("ts")] public string Ts { get; init; } = "";
|
||||
[JsonPropertyName("mono_ns")] public long MonoNs { get; init; }
|
||||
[JsonPropertyName("duration_ms")] public long? DurationMs { get; init; }
|
||||
[JsonPropertyName("outcome")] public CvdiagOutcome Outcome { get; init; }
|
||||
[JsonPropertyName("edge_headers")] public EdgeHeaders EdgeHeaders { get; init; } = new();
|
||||
[JsonPropertyName("metadata")] public Dictionary<string, object?> Metadata { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("_metadata_dropped")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool MetadataDropped { get; set; }
|
||||
|
||||
[JsonPropertyName("_truncated")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool Truncated { get; set; }
|
||||
}
|
||||
|
||||
// ── Per-boundary metadata records (29; schema.json `$defs.boundary_metadata_keys`) ──
|
||||
//
|
||||
// One record per metadata-bearing boundary. These are convenience/typed views
|
||||
// over the boundary's closed key set; the on-wire `metadata` bag stays an open
|
||||
// JSON object (Dictionary<string, object?>) so a single envelope type carries
|
||||
// every boundary. Emitters MAY construct these and pass their property bag to
|
||||
// CvdiagEmitArgs.Metadata. Field names mirror the schema.json metadata keys.
|
||||
|
||||
public sealed record ProbeStartMeta { public string? Url { get; init; } public string? Viewport { get; init; } }
|
||||
public sealed record ProbeNavigateCompleteMeta { public string? Url { get; init; } public long? NavMs { get; init; } public int? HttpStatus { get; init; } }
|
||||
public sealed record ProbeMessageSendMeta { public int? MessageIndex { get; init; } public int? CharCount { get; init; } public string? Demo { get; init; } }
|
||||
public sealed record ProbeDomContainerMountMeta { public long? DeltaMsFromStart { get; init; } }
|
||||
public sealed record ProbeDomFirsttokenMeta { public long? DeltaMsFromStart { get; init; } public int? TextLength { get; init; } }
|
||||
public sealed record ProbeDomAlternateContentMeta { public Dictionary<string, int>? ChildTypeHistogram { get; init; } }
|
||||
public sealed record ProbeSseEventMeta { public string? EventType { get; init; } public int? PayloadSizeBytes { get; init; } public int? SequenceNum { get; init; } }
|
||||
public sealed record ProbeSseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
|
||||
public sealed record ProbeNetworkErrorMeta { public string? Url { get; init; } public string? ErrorClass { get; init; } public int? ResponseStatus { get; init; } }
|
||||
public sealed record ProbeNetworkResponseMeta { public string? Url { get; init; } public int? Status { get; init; } public int? ContentLength { get; init; } public long? DurationMs { get; init; } }
|
||||
public sealed record ProbeConsoleErrorMeta { public string? Level { get; init; } public string? MessageScrubbed { get; init; } public string? SourceFile { get; init; } public string? LineCol { get; init; } }
|
||||
public sealed record ProbeExitMeta { public string? TerminalOutcome { get; init; } public long? TotalDurationMs { get; init; } public int? SseEventCount { get; init; } public long? FirstTokenDeltaMs { get; init; } }
|
||||
public sealed record BackendRequestIngressMeta { public string? Method { get; init; } public string? Path { get; init; } public int? ContentLength { get; init; } }
|
||||
public sealed record BackendAgentEnterMeta { public string? AgentName { get; init; } public string? ModelId { get; init; } }
|
||||
public sealed record BackendLlmCallStartMeta { public string? Provider { get; init; } public string? Model { get; init; } public int? PromptTokenCountEstimate { get; init; } }
|
||||
public sealed record BackendLlmCallHeartbeatMeta { public long? ElapsedMsSinceStart { get; init; } }
|
||||
public sealed record BackendLlmCallResponseMeta { public string? Provider { get; init; } public string? Model { get; init; } public int? ResponseTokenCount { get; init; } public long? LatencyMs { get; init; } public string? ErrorClass { get; init; } }
|
||||
public sealed record BackendSseFirstByteMeta { public long? DeltaMsFromIngress { get; init; } }
|
||||
public sealed record BackendSseEventMeta { public string? EventType { get; init; } public int? PayloadSizeBytes { get; init; } public int? SequenceNum { get; init; } }
|
||||
public sealed record BackendSseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
|
||||
public sealed record BackendAgentExitMeta { public string? TerminalOutcome { get; init; } public long? TotalDurationMs { get; init; } }
|
||||
public sealed record BackendResponseCompleteMeta { public int? HttpStatus { get; init; } public int? ContentLength { get; init; } public long? TotalDurationMs { get; init; } public int? SseEventCount { get; init; } }
|
||||
public sealed record BackendErrorCaughtMeta { public string? ExceptionType { get; init; } public string? MessageScrubbed { get; init; } public string? StackBrief { get; init; } public bool? Truncated { get; init; } }
|
||||
public sealed record AimockRequestIngressMeta { public string? Path { get; init; } public int? ContentLength { get; init; } public string? MatchKeys { get; init; } }
|
||||
public sealed record AimockMatchDecisionMeta { public string? FixtureId { get; init; } public double? MatchScore { get; init; } public IReadOnlyList<string>? RejectReasons { get; init; } }
|
||||
public sealed record AimockResponseStartMeta { public long? DeltaMsFromIngress { get; init; } }
|
||||
public sealed record AimockSseChunkMeta { public int? ChunkSizeBytes { get; init; } public int? SequenceNum { get; init; } }
|
||||
public sealed record AimockResponseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
|
||||
public sealed record AimockResponseCompleteMeta { public int? HttpStatus { get; init; } public int? TotalBytes { get; init; } public long? TotalDurationMs { get; init; } public int? ChunkCount { get; init; } }
|
||||
|
||||
/// <summary>
|
||||
/// Static schema tables derived 1:1 from schema.json `$defs`. These back the
|
||||
/// closed-world metadata filter in <see cref="CvdiagEmitter"/> and the
|
||||
/// codegen-coverage assertions.
|
||||
/// </summary>
|
||||
public static class CvdiagSchema
|
||||
{
|
||||
/// <summary>schema.json `schema_version` (const 1).</summary>
|
||||
public const int SchemaVersion = 1;
|
||||
|
||||
/// <summary>schema.json `$defs.edge_header_keys` (9 keys, wire order).</summary>
|
||||
public static readonly IReadOnlyList<string> EdgeHeaderKeys = new[]
|
||||
{
|
||||
"cf-ray", "cf-mitigated", "cf-cache-status", "x-railway-edge",
|
||||
"x-railway-request-id", "x-hikari-trace", "retry-after", "via", "server",
|
||||
};
|
||||
|
||||
/// <summary>schema.json `$defs.boundaries` (all 33 wire values).</summary>
|
||||
public static readonly IReadOnlyList<string> AllBoundaries = new[]
|
||||
{
|
||||
"probe.start", "probe.navigate.complete", "probe.message.send",
|
||||
"probe.dom.container.mount", "probe.dom.firsttoken",
|
||||
"probe.dom.alternate_content", "probe.sse.event", "probe.sse.aborted",
|
||||
"probe.network.error", "probe.network.response", "probe.console.error",
|
||||
"probe.exit", "backend.request.ingress", "backend.agent.enter",
|
||||
"backend.llm.call.start", "backend.llm.call.heartbeat",
|
||||
"backend.llm.call.response", "backend.sse.first_byte", "backend.sse.event",
|
||||
"backend.sse.aborted", "backend.agent.exit", "backend.response.complete",
|
||||
"backend.error.caught", "aimock.request.ingress", "aimock.match.decision",
|
||||
"aimock.response.start", "aimock.sse.chunk", "aimock.response.aborted",
|
||||
"aimock.response.complete", "cvdiag.purge_audit",
|
||||
"cvdiag.collision_detected", "cvdiag.queue_dropped",
|
||||
"cvdiag.metadata_dropped",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// schema.json `$defs.boundary_metadata_keys` — the closed metadata key
|
||||
/// set per data-plane boundary (29 entries; the 4 `cvdiag.*` accounting
|
||||
/// boundaries carry no closed key set and ride their payload verbatim).
|
||||
/// Keyed by the dotted wire boundary value.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> BoundaryMetadataKeys =
|
||||
new Dictionary<string, IReadOnlyList<string>>
|
||||
{
|
||||
["probe.start"] = new[] { "url", "viewport" },
|
||||
["probe.navigate.complete"] = new[] { "url", "nav_ms", "http_status" },
|
||||
["probe.message.send"] = new[] { "message_index", "char_count", "demo" },
|
||||
["probe.dom.container.mount"] = new[] { "delta_ms_from_start" },
|
||||
["probe.dom.firsttoken"] = new[] { "delta_ms_from_start", "text_length" },
|
||||
["probe.dom.alternate_content"] = new[] { "child_type_histogram" },
|
||||
["probe.sse.event"] = new[] { "event_type", "payload_size_bytes", "sequence_num" },
|
||||
["probe.sse.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
|
||||
["probe.network.error"] = new[] { "url", "error_class", "response_status" },
|
||||
["probe.network.response"] = new[] { "url", "status", "content_length", "duration_ms" },
|
||||
["probe.console.error"] = new[] { "level", "message_scrubbed", "source_file", "line_col" },
|
||||
["probe.exit"] = new[] { "terminal_outcome", "total_duration_ms", "sse_event_count", "first_token_delta_ms" },
|
||||
["backend.request.ingress"] = new[] { "method", "path", "content_length" },
|
||||
["backend.agent.enter"] = new[] { "agent_name", "model_id" },
|
||||
["backend.llm.call.start"] = new[] { "provider", "model", "prompt_token_count_estimate" },
|
||||
["backend.llm.call.heartbeat"] = new[] { "elapsed_ms_since_start" },
|
||||
["backend.llm.call.response"] = new[] { "provider", "model", "response_token_count", "latency_ms", "error_class" },
|
||||
["backend.sse.first_byte"] = new[] { "delta_ms_from_ingress" },
|
||||
["backend.sse.event"] = new[] { "event_type", "payload_size_bytes", "sequence_num" },
|
||||
["backend.sse.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
|
||||
["backend.agent.exit"] = new[] { "terminal_outcome", "total_duration_ms" },
|
||||
["backend.response.complete"] = new[] { "http_status", "content_length", "total_duration_ms", "sse_event_count" },
|
||||
["backend.error.caught"] = new[] { "exception_type", "message_scrubbed", "stack_brief", "truncated" },
|
||||
["aimock.request.ingress"] = new[] { "path", "content_length", "match_keys" },
|
||||
["aimock.match.decision"] = new[] { "fixture_id", "match_score", "reject_reasons" },
|
||||
["aimock.response.start"] = new[] { "delta_ms_from_ingress" },
|
||||
["aimock.sse.chunk"] = new[] { "chunk_size_bytes", "sequence_num" },
|
||||
["aimock.response.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
|
||||
["aimock.response.complete"] = new[] { "http_status", "total_bytes", "total_duration_ms", "chunk_count" },
|
||||
};
|
||||
|
||||
/// <summary>The dotted wire value for a boundary enum member.</summary>
|
||||
public static string WireValue(CvdiagBoundary boundary) => boundary switch
|
||||
{
|
||||
CvdiagBoundary.ProbeStart => "probe.start",
|
||||
CvdiagBoundary.ProbeNavigateComplete => "probe.navigate.complete",
|
||||
CvdiagBoundary.ProbeMessageSend => "probe.message.send",
|
||||
CvdiagBoundary.ProbeDomContainerMount => "probe.dom.container.mount",
|
||||
CvdiagBoundary.ProbeDomFirsttoken => "probe.dom.firsttoken",
|
||||
CvdiagBoundary.ProbeDomAlternateContent => "probe.dom.alternate_content",
|
||||
CvdiagBoundary.ProbeSseEvent => "probe.sse.event",
|
||||
CvdiagBoundary.ProbeSseAborted => "probe.sse.aborted",
|
||||
CvdiagBoundary.ProbeNetworkError => "probe.network.error",
|
||||
CvdiagBoundary.ProbeNetworkResponse => "probe.network.response",
|
||||
CvdiagBoundary.ProbeConsoleError => "probe.console.error",
|
||||
CvdiagBoundary.ProbeExit => "probe.exit",
|
||||
CvdiagBoundary.BackendRequestIngress => "backend.request.ingress",
|
||||
CvdiagBoundary.BackendAgentEnter => "backend.agent.enter",
|
||||
CvdiagBoundary.BackendLlmCallStart => "backend.llm.call.start",
|
||||
CvdiagBoundary.BackendLlmCallHeartbeat => "backend.llm.call.heartbeat",
|
||||
CvdiagBoundary.BackendLlmCallResponse => "backend.llm.call.response",
|
||||
CvdiagBoundary.BackendSseFirstByte => "backend.sse.first_byte",
|
||||
CvdiagBoundary.BackendSseEvent => "backend.sse.event",
|
||||
CvdiagBoundary.BackendSseAborted => "backend.sse.aborted",
|
||||
CvdiagBoundary.BackendAgentExit => "backend.agent.exit",
|
||||
CvdiagBoundary.BackendResponseComplete => "backend.response.complete",
|
||||
CvdiagBoundary.BackendErrorCaught => "backend.error.caught",
|
||||
CvdiagBoundary.AimockRequestIngress => "aimock.request.ingress",
|
||||
CvdiagBoundary.AimockMatchDecision => "aimock.match.decision",
|
||||
CvdiagBoundary.AimockResponseStart => "aimock.response.start",
|
||||
CvdiagBoundary.AimockSseChunk => "aimock.sse.chunk",
|
||||
CvdiagBoundary.AimockResponseAborted => "aimock.response.aborted",
|
||||
CvdiagBoundary.AimockResponseComplete => "aimock.response.complete",
|
||||
CvdiagBoundary.CvdiagPurgeAudit => "cvdiag.purge_audit",
|
||||
CvdiagBoundary.CvdiagCollisionDetected => "cvdiag.collision_detected",
|
||||
CvdiagBoundary.CvdiagQueueDropped => "cvdiag.queue_dropped",
|
||||
CvdiagBoundary.CvdiagMetadataDropped => "cvdiag.metadata_dropped",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(boundary), boundary, null),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json source-generation context. Carries the [JsonSerializable]
|
||||
/// roots so serialization is reflection-free (AOT/trim-safe). The snake_case
|
||||
/// policy is NOT applied globally — every field already declares its exact
|
||||
/// wire name via [JsonPropertyName], so we keep the default naming and rely on
|
||||
/// the explicit attributes (avoids double-transforming hyphenated edge keys).
|
||||
///
|
||||
/// IMPORTANT: we do NOT set a context-wide DefaultIgnoreCondition. The schema
|
||||
/// REQUIRES the nullable fields (`parent_span_id`, `duration_ms`) to appear on
|
||||
/// the wire as `null`, so a global WhenWritingDefault/WhenWritingNull would
|
||||
/// drop them and break the closed-world `required` contract. The only fields
|
||||
/// that should be omitted when default are the two `_`-prefixed stamp flags,
|
||||
/// which carry their own per-property [JsonIgnore(WhenWritingDefault)].
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(WriteIndented = false)]
|
||||
[JsonSerializable(typeof(CvdiagEnvelope))]
|
||||
[JsonSerializable(typeof(EdgeHeaders))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
public partial class CvdiagJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Standalone xUnit test project for the shared .NET CVDIAG binding (plan unit
|
||||
L0-D). The `_shared/dotnet/` directory is a SOURCE-INCLUDE shared library
|
||||
(no production .csproj of its own — see the L1-F reference note in the slot
|
||||
report), so this test project compiles the two binding sources directly via
|
||||
<Compile Include> rather than a <ProjectReference>. This keeps the binding
|
||||
buildable/testable in isolation without a host integration.
|
||||
|
||||
System.Text.Json source-generation requires C# 9+ partial-class contexts and
|
||||
the JsonStringEnumMemberName attribute (net8.0+); we target net9.0 to match
|
||||
the .NET integrations (ProverbsAgent.csproj is net9.0).
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>Copilotkit.Showcase.Cvdiag.Tests</RootNamespace>
|
||||
<AssemblyName>Cvdiag.Schema.Tests</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Compile the shared binding sources directly (source-include model). -->
|
||||
<ItemGroup>
|
||||
<Compile Include="..\CvdiagSchema.cs" />
|
||||
<Compile Include="..\CvdiagEmitter.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,262 @@
|
||||
// CvdiagSchema.test.cs — xUnit red-green proof for the .NET CVDIAG codegen
|
||||
// binding (plan unit L0-D, spec §5/§6). Mirrors the TS `schema.test.ts` /
|
||||
// `emit.ts` contract:
|
||||
//
|
||||
// 1. schema round-trip — a fully-populated CvdiagEnvelope serializes to JSON
|
||||
// and deserializes back identically (System.Text.Json source-gen).
|
||||
// 2. forbidden-header rejection — CvdiagEmitter.FilterEdgeHeaders() drops a
|
||||
// DENY-list header (cf-ipcountry) and the cf-ip* family even if it is
|
||||
// injected alongside an allow-list header; every result carries all 9
|
||||
// allow-list keys (absent → null).
|
||||
// 3. _metadata_dropped stamp — an envelope carrying an unknown metadata key
|
||||
// is closed-world filtered and the `_metadata_dropped` flag is set.
|
||||
// 4. production-env DEBUG refusal — CvdiagEmitter fail-closes (throws) when
|
||||
// DEBUG is requested in a production environment, when no env resolves,
|
||||
// and when the allow-list is absent.
|
||||
//
|
||||
// RED: with no CvdiagSchema.cs / CvdiagEmitter.cs these types do not exist and
|
||||
// the suite does not compile. GREEN once both are implemented.
|
||||
//
|
||||
// Run (from repo root, requires the .NET 9 SDK):
|
||||
// dotnet test showcase/integrations/_shared/dotnet/tests/CvdiagSchema.Tests.csproj
|
||||
|
||||
using System.Text.Json;
|
||||
using Copilotkit.Showcase.Cvdiag;
|
||||
using Xunit;
|
||||
|
||||
namespace Copilotkit.Showcase.Cvdiag.Tests;
|
||||
|
||||
public class CvdiagSchemaTests
|
||||
{
|
||||
private static EdgeHeaders EmptyEdgeHeaders() => new();
|
||||
|
||||
private static CvdiagEnvelope SampleEnvelope() => new()
|
||||
{
|
||||
SchemaVersion = CvdiagSchema.SchemaVersion,
|
||||
TestId = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f",
|
||||
TraceId = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f",
|
||||
SpanId = "00f067aa0ba902b7",
|
||||
ParentSpanId = null,
|
||||
Layer = CvdiagLayer.Backend,
|
||||
Boundary = CvdiagBoundary.BackendResponseComplete,
|
||||
Slug = "gen-ui-chat",
|
||||
Demo = "default",
|
||||
Ts = "2026-06-18T12:00:00.000Z",
|
||||
MonoNs = 123456789,
|
||||
DurationMs = 42,
|
||||
Outcome = CvdiagOutcome.Ok,
|
||||
EdgeHeaders = EmptyEdgeHeaders(),
|
||||
Metadata = new Dictionary<string, object?> { ["http_status"] = 200 },
|
||||
};
|
||||
|
||||
// (1) Round-trip: serialize → deserialize → equal field-by-field.
|
||||
[Fact]
|
||||
public void Envelope_RoundTrips_ThroughSystemTextJson()
|
||||
{
|
||||
var original = SampleEnvelope();
|
||||
var json = JsonSerializer.Serialize(original, CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
var back = JsonSerializer.Deserialize(json, CvdiagJsonContext.Default.CvdiagEnvelope)!;
|
||||
|
||||
Assert.Equal(original.SchemaVersion, back.SchemaVersion);
|
||||
Assert.Equal(original.TestId, back.TestId);
|
||||
Assert.Equal(original.SpanId, back.SpanId);
|
||||
Assert.Equal(original.Layer, back.Layer);
|
||||
Assert.Equal(original.Boundary, back.Boundary);
|
||||
Assert.Equal(original.Slug, back.Slug);
|
||||
Assert.Equal(original.Outcome, back.Outcome);
|
||||
Assert.Equal(original.MonoNs, back.MonoNs);
|
||||
Assert.Equal(original.DurationMs, back.DurationMs);
|
||||
}
|
||||
|
||||
// (1b) Wire format: enum + key names match the schema.json contract exactly
|
||||
// (snake_case keys, dotted/lowercase enum string values).
|
||||
[Fact]
|
||||
public void Envelope_SerializesWithSchemaWireNames()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(SampleEnvelope(), CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
Assert.Contains("\"schema_version\":1", json);
|
||||
Assert.Contains("\"test_id\":", json);
|
||||
Assert.Contains("\"parent_span_id\":null", json);
|
||||
Assert.Contains("\"edge_headers\":", json);
|
||||
Assert.Contains("\"layer\":\"backend\"", json);
|
||||
Assert.Contains("\"boundary\":\"backend.response.complete\"", json);
|
||||
Assert.Contains("\"outcome\":\"ok\"", json);
|
||||
// cf-ray etc. serialize with their hyphenated wire names.
|
||||
Assert.Contains("\"cf-ray\":null", json);
|
||||
Assert.Contains("\"x-railway-request-id\":null", json);
|
||||
}
|
||||
|
||||
// (2) Forbidden-header rejection: cf-ipcountry (DENY) is dropped even when
|
||||
// injected next to an allow-list header; the cf-ip* family is never
|
||||
// captured; all 9 allow-list keys are present (absent → null).
|
||||
[Fact]
|
||||
public void FilterEdgeHeaders_RejectsDenyListAndCfIpFamily()
|
||||
{
|
||||
var raw = new Dictionary<string, string?>
|
||||
{
|
||||
["cf-ray"] = "8abc-DFW", // allow → kept
|
||||
["cf-ipcountry"] = "US", // deny → dropped
|
||||
["cf-connecting-ip"] = "1.2.3.4", // deny → dropped
|
||||
["true-client-ip"] = "1.2.3.4", // deny → dropped
|
||||
["x-forwarded-for"] = "1.2.3.4", // deny → dropped
|
||||
["CF-IPCity"] = "Dallas", // deny (case-insensitive)
|
||||
["server"] = "railway-edge", // allow → kept
|
||||
};
|
||||
|
||||
var filtered = CvdiagEmitter.FilterEdgeHeaders(raw);
|
||||
|
||||
Assert.Equal("8abc-DFW", filtered.CfRay);
|
||||
Assert.Equal("railway-edge", filtered.Server);
|
||||
// DENY-list values never surface on any of the 9 fields.
|
||||
Assert.DoesNotContain("US", filtered.AllValues());
|
||||
Assert.DoesNotContain("1.2.3.4", filtered.AllValues());
|
||||
Assert.DoesNotContain("Dallas", filtered.AllValues());
|
||||
// All 9 allow-list keys present; unset → null.
|
||||
Assert.Null(filtered.CfMitigated);
|
||||
Assert.Null(filtered.XRailwayRequestId);
|
||||
Assert.Null(filtered.RetryAfter);
|
||||
Assert.Equal(9, filtered.KeyCount());
|
||||
}
|
||||
|
||||
// (2b) Deny wins even if a deny key somehow appears in the allow path.
|
||||
[Fact]
|
||||
public void FilterEdgeHeaders_DenyWinsOverAllow()
|
||||
{
|
||||
var raw = new Dictionary<string, string?> { ["forwarded"] = "for=1.2.3.4" };
|
||||
var filtered = CvdiagEmitter.FilterEdgeHeaders(raw);
|
||||
Assert.DoesNotContain("for=1.2.3.4", filtered.AllValues());
|
||||
}
|
||||
|
||||
// (3) _metadata_dropped stamp: an unknown metadata key for the boundary is
|
||||
// dropped (closed-world) and the flag is stamped.
|
||||
[Fact]
|
||||
public void Emit_StampsMetadataDropped_OnUnknownKey()
|
||||
{
|
||||
var emitter = CvdiagEmitterTestFactory.Verbose();
|
||||
var env = emitter.Emit(new CvdiagEmitArgs
|
||||
{
|
||||
Layer = CvdiagLayer.Backend,
|
||||
Boundary = CvdiagBoundary.BackendResponseComplete,
|
||||
Slug = "gen-ui-chat",
|
||||
Demo = "default",
|
||||
Outcome = CvdiagOutcome.Ok,
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["http_status"] = 200, // allowed for this boundary
|
||||
["totally_unknown_key"] = 1, // not in the closed key set → dropped
|
||||
},
|
||||
});
|
||||
|
||||
Assert.NotNull(env);
|
||||
Assert.True(env!.MetadataDropped);
|
||||
Assert.True(env.Metadata.ContainsKey("http_status"));
|
||||
Assert.False(env.Metadata.ContainsKey("totally_unknown_key"));
|
||||
}
|
||||
|
||||
// (3b) Clean metadata leaves the flag unset.
|
||||
[Fact]
|
||||
public void Emit_DoesNotStampMetadataDropped_OnCleanMetadata()
|
||||
{
|
||||
var emitter = CvdiagEmitterTestFactory.Verbose();
|
||||
var env = emitter.Emit(new CvdiagEmitArgs
|
||||
{
|
||||
Layer = CvdiagLayer.Backend,
|
||||
Boundary = CvdiagBoundary.BackendAgentEnter,
|
||||
Slug = "gen-ui-chat",
|
||||
Demo = "default",
|
||||
Outcome = CvdiagOutcome.Ok,
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["agent_name"] = "proverbs",
|
||||
["model_id"] = "gpt-4o-mini",
|
||||
},
|
||||
});
|
||||
|
||||
Assert.NotNull(env);
|
||||
Assert.False(env!.MetadataDropped);
|
||||
}
|
||||
|
||||
// (4) Production-env DEBUG refusal: SHOWCASE_ENV=production + DEBUG throws.
|
||||
[Fact]
|
||||
public void DebugInProduction_FailsClosed()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["SHOWCASE_ENV"] = "production",
|
||||
["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat",
|
||||
};
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
|
||||
}
|
||||
|
||||
// (4b) Unresolved env (no SHOWCASE_ENV/RAILWAY/ASPNETCORE) treated as prod.
|
||||
[Fact]
|
||||
public void DebugWithUnresolvedEnv_FailsClosed()
|
||||
{
|
||||
var env = new Dictionary<string, string?> { ["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat" };
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
|
||||
}
|
||||
|
||||
// (4c) DEBUG in a non-prod env without an allow-list still refuses.
|
||||
[Fact]
|
||||
public void DebugWithoutAllowList_FailsClosed()
|
||||
{
|
||||
var env = new Dictionary<string, string?> { ["SHOWCASE_ENV"] = "staging" };
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
|
||||
}
|
||||
|
||||
// (4d) DEBUG in a non-prod env WITH an allow-list is permitted (no throw).
|
||||
[Fact]
|
||||
public void DebugInStagingWithAllowList_IsAllowed()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["SHOWCASE_ENV"] = "staging",
|
||||
["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat",
|
||||
};
|
||||
var emitter = new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env });
|
||||
Assert.Equal(CvdiagTier.Debug, emitter.Tier);
|
||||
}
|
||||
|
||||
// (4e) Tier env precedence: ASPNETCORE_ENVIRONMENT is the last fallback
|
||||
// (the .NET analogue of NODE_ENV).
|
||||
[Fact]
|
||||
public void ResolveEnvLabel_FallsBackToAspNetCoreEnvironment()
|
||||
{
|
||||
var env = new Dictionary<string, string?> { ["ASPNETCORE_ENVIRONMENT"] = "Production" };
|
||||
Assert.Equal("production", CvdiagEmitter.ResolveEnvLabel(env));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvLabel_ShowcaseEnvWins()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["SHOWCASE_ENV"] = "staging",
|
||||
["RAILWAY_ENVIRONMENT_NAME"] = "production",
|
||||
["ASPNETCORE_ENVIRONMENT"] = "Production",
|
||||
};
|
||||
Assert.Equal("staging", CvdiagEmitter.ResolveEnvLabel(env));
|
||||
}
|
||||
|
||||
// Codegen coverage: all 29 metadata-bearing boundaries have a closed key set.
|
||||
[Fact]
|
||||
public void AllBoundaries_HaveClosedMetadataKeySets()
|
||||
{
|
||||
Assert.Equal(33, CvdiagSchema.AllBoundaries.Count);
|
||||
Assert.Equal(29, CvdiagSchema.BoundaryMetadataKeys.Count);
|
||||
Assert.Equal(9, CvdiagSchema.EdgeHeaderKeys.Count);
|
||||
}
|
||||
}
|
||||
|
||||
// Small factory: a VERBOSE-tier emitter with no PB writer for metadata tests.
|
||||
internal static class CvdiagEmitterTestFactory
|
||||
{
|
||||
public static CvdiagEmitter Verbose() => new(new CvdiagEmitterOptions
|
||||
{
|
||||
Verbose = true,
|
||||
Env = new Dictionary<string, string?> { ["SHOWCASE_ENV"] = "staging" },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user