Add capture index manifest for group testing
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user