Add capture index manifest for group testing

This commit is contained in:
Jacob Dubin
2026-05-17 14:07:56 -05:00
parent c0485da46d
commit 14b5cb74cc
10 changed files with 198 additions and 17 deletions

View File

@@ -461,6 +461,7 @@ Current release theme:
- Implementation notes: - Implementation notes:
- define local capture sinks versus hosted retention - define local capture sinks versus hosted retention
- decide how testers submit noteworthy sessions - decide how testers submit noteworthy sessions
- keep a lightweight `capture-index.ndjson` manifest beside raw captures so testers can quickly find sessions, operations, and fixture exports
- preserve sanitized fixtures as the durable parity artifact - preserve sanitized fixtures as the durable parity artifact
### 11. Binary-Safe Media Storage ### 11. Binary-Safe Media Storage

View File

@@ -41,6 +41,7 @@ The `.NET` cloud now supports structured live capture intended for first robot r
- HTTP request/response event streams written as NDJSON - HTTP request/response event streams written as NDJSON
- websocket event streams written as NDJSON - websocket event streams written as NDJSON
- per-session websocket fixture export for replay - per-session websocket fixture export for replay
- a small `capture-index.ndjson` manifest beside the raw files so group testers can quickly find the session type, operation, and export artifacts
- turn metadata including `transID`, buffered audio counts, finalize attempts, and reply types - turn metadata including `transID`, buffered audio counts, finalize attempts, and reply types
Default capture location: Default capture location:
@@ -54,6 +55,7 @@ Artifacts:
- `websocket/*.events.ndjson` - `websocket/*.events.ndjson`
- `*.events.ndjson` - `*.events.ndjson`
- `websocket/fixtures/*.flow.json` - `websocket/fixtures/*.flow.json`
- `capture-index.ndjson`
## Suggested First Hookup Plan ## Suggested First Hookup Plan
@@ -61,8 +63,9 @@ Artifacts:
2. Confirm HTTP bootstrap and websocket acceptance with the existing smoke/routing helpers. 2. Confirm HTTP bootstrap and websocket acceptance with the existing smoke/routing helpers.
3. Run one or two controlled listen turns with Jibo. 3. Run one or two controlled listen turns with Jibo.
4. Inspect the captured HTTP and websocket events plus exported websocket fixtures. 4. Inspect the captured HTTP and websocket events plus exported websocket fixtures.
5. Convert the best captures into sanitized checked-in fixtures and tests. 5. Use `capture-index.ndjson` to quickly locate the important sessions and exported fixtures.
6. Keep Node available to compare any surprising turn behavior before changing infrastructure. 6. Convert the best captures into sanitized checked-in fixtures and tests.
7. Keep Node available to compare any surprising turn behavior before changing infrastructure.
Useful helper scripts: Useful helper scripts:

View File

@@ -342,7 +342,7 @@ First completed slice in this personal-report parity track:
7. Update/backup/restore end-to-end proof - implemented 7. Update/backup/restore end-to-end proof - implemented
8. STT noise-screening and short-utterance reliability pass 8. STT noise-screening and short-utterance reliability pass
9. Provider-backed news expansion and deeper weather parity 9. Provider-backed news expansion and deeper weather parity
10. Capture indexing and retention boundary for group testing 10. Capture indexing and retention boundary for group testing, including a lightweight manifest beside raw capture files
For slice 1, use the new import ladder above to keep the work grounded in what OpenJibo can already render today versus what needs new scaffolding. For slice 1, use the new import ladder above to keep the work grounded in what OpenJibo can already render today versus what needs new scaffolding.
For slices 2-5, use Pegasus phrase lists, MIM IDs, and behavior patterns as the source anchor before broadening into OpenJibo-native improvements. For slices 2-5, use Pegasus phrase lists, MIM IDs, and behavior patterns as the source anchor before broadening into OpenJibo-native improvements.

View File

@@ -0,0 +1,48 @@
using System.Collections.Concurrent;
using System.Text.Json;
namespace Jibo.Cloud.Infrastructure.Telemetry;
internal static class CaptureIndexWriter
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private static readonly ConcurrentDictionary<string, SemaphoreSlim> DirectoryLocks = new(StringComparer.OrdinalIgnoreCase);
public static async Task AppendAsync(
string directoryPath,
string sinkName,
string eventType,
IReadOnlyDictionary<string, object?> details,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(directoryPath)) return;
var directory = Path.GetFullPath(directoryPath);
Directory.CreateDirectory(directory);
var indexPath = Path.Combine(directory, "capture-index.ndjson");
var payload = new
{
capturedUtc = DateTimeOffset.UtcNow,
sink = sinkName,
eventType,
details
};
var line = JsonSerializer.Serialize(payload, JsonOptions) + Environment.NewLine;
var gate = DirectoryLocks.GetOrAdd(directory, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
try
{
await File.AppendAllTextAsync(indexPath, line, cancellationToken);
}
finally
{
gate.Release();
}
}
}

View File

@@ -64,6 +64,23 @@ public sealed class FileProtocolTelemetrySink(
_writeLock.Release(); _writeLock.Release();
} }
await CaptureIndexWriter.AppendAsync(
directory,
"http",
"protocol_record",
new Dictionary<string, object?>
{
["method"] = envelope.Method,
["host"] = envelope.HostName,
["path"] = envelope.Path,
["servicePrefix"] = envelope.ServicePrefix,
["operation"] = envelope.Operation,
["statusCode"] = result.StatusCode,
["contentType"] = result.ContentType,
["requestId"] = envelope.RequestId
},
cancellationToken);
logger.LogInformation( logger.LogInformation(
"HTTP telemetry {Method} {Host}{Path} target={Target} status={StatusCode}", "HTTP telemetry {Method} {Host}{Path} target={Target} status={StatusCode}",
envelope.Method, envelope.Method,
@@ -72,4 +89,4 @@ public sealed class FileProtocolTelemetrySink(
$"{envelope.ServicePrefix}.{envelope.Operation}".Trim('.'), $"{envelope.ServicePrefix}.{envelope.Operation}".Trim('.'),
result.StatusCode); result.StatusCode);
} }
} }

View File

@@ -21,7 +21,7 @@ public sealed class FileTurnTelemetrySink(
{ {
if (!options.Value.Enabled) return; if (!options.Value.Enabled) return;
await WriteEventAsync(new await WriteEventAsync(category, new
{ {
Type = category, Type = category,
Details = details Details = details
@@ -32,7 +32,7 @@ public sealed class FileTurnTelemetrySink(
{ {
if (!options.Value.Enabled) return; if (!options.Value.Enabled) return;
await WriteEventAsync(new await WriteEventAsync("transcript_error", new
{ {
Exception = ex.ToString(), Exception = ex.ToString(),
Message = message, Message = message,
@@ -40,7 +40,7 @@ public sealed class FileTurnTelemetrySink(
}, "Turn telemetry error", LogLevel.Error, cancellationToken); }, "Turn telemetry error", LogLevel.Error, cancellationToken);
} }
private async Task WriteEventAsync(object payload, string logMessage, LogLevel level, private async Task WriteEventAsync(string eventType, object payload, string logMessage, LogLevel level,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var directory = GetBaseDirectory(); var directory = GetBaseDirectory();
@@ -58,6 +58,17 @@ public sealed class FileTurnTelemetrySink(
_writeLock.Release(); _writeLock.Release();
} }
await CaptureIndexWriter.AppendAsync(
directory,
"turn",
eventType,
new Dictionary<string, object?>
{
["message"] = logMessage,
["level"] = level.ToString()
},
cancellationToken);
logger.Log(level, "{LogMessage} {Payload}", logMessage, payload); logger.Log(level, "{LogMessage} {Payload}", logMessage, payload);
} }
@@ -68,4 +79,4 @@ public sealed class FileTurnTelemetrySink(
Directory.GetCurrentDirectory(), Directory.GetCurrentDirectory(),
AppContext.BaseDirectory); AppContext.BaseDirectory);
} }
} }

View File

@@ -39,15 +39,20 @@ public sealed class FileWebSocketTelemetrySink(
await WriteRecordAsync(BuildRecord("connection_opened", envelope, session, null, "internal", null, null), await WriteRecordAsync(BuildRecord("connection_opened", envelope, session, null, "internal", null, null),
cancellationToken); cancellationToken);
await AppendIndexAsync(envelope, session, "connection_opened", null, cancellationToken);
} }
public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, public async Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
return !options.Value.Enabled if (!options.Value.Enabled) return;
? Task.CompletedTask
: WriteRecordAsync(BuildRecord("message_in", envelope, session, messageType, "in", null, null), await WriteRecordAsync(BuildRecord("message_in", envelope, session, messageType, "in", null, null),
cancellationToken); cancellationToken);
await AppendIndexAsync(envelope, session, "message_in", new Dictionary<string, object?>
{
["messageType"] = messageType
}, cancellationToken);
} }
public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType,
@@ -72,6 +77,10 @@ public sealed class FileWebSocketTelemetrySink(
await WriteRecordAsync(BuildRecord("message_out", envelope, session, null, "out", replyTypes, null), await WriteRecordAsync(BuildRecord("message_out", envelope, session, null, "out", replyTypes, null),
cancellationToken); cancellationToken);
await AppendIndexAsync(envelope, session, "message_out", new Dictionary<string, object?>
{
["replyTypes"] = replyTypes
}, cancellationToken);
if (_fixtures.TryGetValue(session.SessionId, out var fixture)) if (_fixtures.TryGetValue(session.SessionId, out var fixture))
fixture.Steps.Add(new CapturedWebSocketFixtureStep fixture.Steps.Add(new CapturedWebSocketFixtureStep
@@ -95,6 +104,10 @@ public sealed class FileWebSocketTelemetrySink(
"internal", "internal",
null, null,
new Dictionary<string, object?> { ["reason"] = reason }), cancellationToken); new Dictionary<string, object?> { ["reason"] = reason }), cancellationToken);
await AppendIndexAsync(envelope, session, "connection_closed", new Dictionary<string, object?>
{
["reason"] = reason
}, cancellationToken);
if (!options.Value.ExportFixtures || !_fixtures.TryRemove(session.SessionId, out var fixture) || if (!options.Value.ExportFixtures || !_fixtures.TryRemove(session.SessionId, out var fixture) ||
fixture.Steps.Count == 0) return; fixture.Steps.Count == 0) return;
@@ -122,6 +135,13 @@ public sealed class FileWebSocketTelemetrySink(
_writeLock.Release(); _writeLock.Release();
} }
await AppendIndexAsync(envelope, session, "fixture_export", new Dictionary<string, object?>
{
["fixturePath"] = fixturePath,
["stepCount"] = fixture.Steps.Count,
["fixtureName"] = fixtureName
}, cancellationToken);
logger.LogInformation("Exported websocket fixture {FixturePath}", fixturePath); logger.LogInformation("Exported websocket fixture {FixturePath}", fixturePath);
} }
@@ -223,6 +243,31 @@ public sealed class FileWebSocketTelemetrySink(
AppContext.BaseDirectory); AppContext.BaseDirectory);
} }
private async Task AppendIndexAsync(
WebSocketMessageEnvelope envelope,
CloudSession session,
string eventType,
IReadOnlyDictionary<string, object?>? details,
CancellationToken cancellationToken)
{
var directory = GetBaseDirectory();
await CaptureIndexWriter.AppendAsync(
directory,
"websocket",
eventType,
new Dictionary<string, object?>
{
["sessionId"] = session.SessionId,
["hostName"] = envelope.HostName,
["path"] = envelope.Path,
["kind"] = envelope.Kind,
["token"] = envelope.Token,
["transId"] = session.TurnState.TransId ?? session.LastTransId,
["details"] = details
},
cancellationToken);
}
private static string BuildFixtureName(CloudSession session, CapturedWebSocketFixtureBuilder fixture) private static string BuildFixtureName(CloudSession session, CapturedWebSocketFixtureBuilder fixture)
{ {
var host = SanitizeName(fixture.Session.HostName); var host = SanitizeName(fixture.Session.HostName);
@@ -246,4 +291,4 @@ public sealed class FileWebSocketTelemetrySink(
public CapturedWebSocketFixtureSession Session { get; init; } = new(); public CapturedWebSocketFixtureSession Session { get; init; } = new();
public List<CapturedWebSocketFixtureStep> Steps { get; } = []; public List<CapturedWebSocketFixtureStep> Steps { get; } = [];
} }
} }

View File

@@ -55,8 +55,13 @@ public sealed class FileProtocolTelemetrySinkTests : IDisposable
var captureFile = Directory.GetFiles(captureDirectory, "*.events.ndjson").Single(); var captureFile = Directory.GetFiles(captureDirectory, "*.events.ndjson").Single();
var contents = await File.ReadAllTextAsync(captureFile); var contents = await File.ReadAllTextAsync(captureFile);
var indexPath = Path.Combine(captureDirectory, "capture-index.ndjson");
var indexLines = await File.ReadAllLinesAsync(indexPath);
Assert.Contains("Notification_20150505", contents); Assert.Contains("Notification_20150505", contents);
Assert.DoesNotContain(Path.Combine("bin", "Debug"), captureFile, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(Path.Combine("bin", "Debug"), captureFile, StringComparison.OrdinalIgnoreCase);
Assert.Contains(indexLines, line => line.Contains("\"eventType\":\"protocol_record\"", StringComparison.Ordinal));
Assert.Contains(indexLines, line => line.Contains("\"servicePrefix\":\"Notification_20150505\"",
StringComparison.Ordinal));
} }
} }

View File

@@ -39,6 +39,35 @@ public sealed class FileTurnTelemetrySinkTests
Assert.Equal(1234, payload.GetProperty("details").GetProperty("bufferedAudioBytes").GetInt32()); Assert.Equal(1234, payload.GetProperty("details").GetProperty("bufferedAudioBytes").GetInt32());
} }
[Fact]
public async Task RecordsCaptureIndexForTurnDiagnosticsAndErrors()
{
var directoryPath = Path.Combine(Path.GetTempPath(), "OpenJibo.Tests", Guid.NewGuid().ToString("N"));
var sink = new FileTurnTelemetrySink(
NullLogger<FileTurnTelemetrySink>.Instance,
Options.Create(new TurnTelemetryOptions
{
Enabled = true,
DirectoryPath = directoryPath
}));
await sink.RecordTurnDiagnosticAsync("yes_no_turn_received", new Dictionary<string, object?>
{
["transID"] = "trans-1",
["bufferedAudioBytes"] = 1234
});
await sink.RecordTranscriptError(new InvalidOperationException("boom"), "turn error");
var indexPath = Path.Combine(directoryPath, "capture-index.ndjson");
var lines = await File.ReadAllLinesAsync(indexPath);
Assert.Contains(lines, line => line.Contains("\"eventType\":\"yes_no_turn_received\"", StringComparison.Ordinal));
Assert.Contains(lines, line => line.Contains("\"eventType\":\"transcript_error\"", StringComparison.Ordinal));
Assert.Contains(lines, line => line.Contains("\"message\":\"Turn telemetry diagnostic\"", StringComparison.Ordinal));
Assert.Contains(lines, line => line.Contains("\"message\":\"Turn telemetry error\"", StringComparison.Ordinal));
}
[Fact] [Fact]
public async Task RecordsTranscriptErrorOnTurnError() public async Task RecordsTranscriptErrorOnTurnError()
{ {
@@ -148,4 +177,4 @@ public sealed class FileTurnTelemetrySinkTests
It.IsAny<CancellationToken>()), It.IsAny<CancellationToken>()),
Times.AtLeastOnce()); Times.AtLeastOnce());
} }
} }

View File

@@ -66,6 +66,14 @@ public sealed class FileWebSocketTelemetrySinkTests : IDisposable
Assert.Equal(1, document.RootElement.GetProperty("steps").GetArrayLength()); Assert.Equal(1, document.RootElement.GetProperty("steps").GetArrayLength());
Assert.Equal("LISTEN", Assert.Equal("LISTEN",
document.RootElement.GetProperty("steps")[0].GetProperty("expectedReplyTypes")[0].GetString()); document.RootElement.GetProperty("steps")[0].GetProperty("expectedReplyTypes")[0].GetString());
var indexPath = Path.Combine(_directoryPath, "capture-index.ndjson");
var indexEntries = await ReadNdjsonAsync(indexPath);
Assert.Contains(indexEntries, entry => entry.GetProperty("eventType").GetString() == "connection_opened");
Assert.Contains(indexEntries, entry => entry.GetProperty("eventType").GetString() == "message_in");
Assert.Contains(indexEntries, entry => entry.GetProperty("eventType").GetString() == "message_out");
Assert.Contains(indexEntries, entry => entry.GetProperty("eventType").GetString() == "connection_closed");
Assert.Contains(indexEntries, entry => entry.GetProperty("eventType").GetString() == "fixture_export");
} }
[Fact] [Fact]
@@ -124,4 +132,18 @@ public sealed class FileWebSocketTelemetrySinkTests : IDisposable
DirectoryPath = _directoryPath DirectoryPath = _directoryPath
})); }));
} }
}
private static async Task<List<JsonElement>> ReadNdjsonAsync(string filePath)
{
var entries = new List<JsonElement>();
foreach (var line in await File.ReadAllLinesAsync(filePath))
{
if (string.IsNullOrWhiteSpace(line)) continue;
using var document = JsonDocument.Parse(line);
entries.Add(document.RootElement.Clone());
}
return entries;
}
}