V13-FE-011: finalize search list layout slice
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
||||
// generated from contracts/telemetry/kbx.telemetry.json; do not edit.
|
||||
namespace Kbx.Shared.Telemetry.Generated;
|
||||
|
||||
public sealed record KbxTelemetryEventDefinition(string Name,string Category,IReadOnlyList<string> AllowedAttributes,bool RequiresDuration);
|
||||
|
||||
public static class KbxTelemetryCatalog
|
||||
{
|
||||
public const string SourceSha256 = "9f5de69498b657cb0d60df18c1e98c504a44eec555c4fc50d46f15f83a4ea1ce";
|
||||
public static readonly IReadOnlyDictionary<string,KbxTelemetryEventDefinition> Events = new Dictionary<string,KbxTelemetryEventDefinition>(StringComparer.Ordinal)
|
||||
{
|
||||
["screen.open"] = new("screen.open", "navigation", new[] { "module", "launchMode" }, false),
|
||||
["screen.close"] = new("screen.close", "navigation", new[] { "module", "closeReason" }, false),
|
||||
["task.start"] = new("task.start", "task", new[] { "taskType" }, false),
|
||||
["task.complete"] = new("task.complete", "task", new[] { "taskType", "result" }, true),
|
||||
["task.abandon"] = new("task.abandon", "task", new[] { "taskType", "reasonCode" }, true),
|
||||
["interaction.execute"] = new("interaction.execute", "interaction", new[] { "interactionType", "commandId" }, false),
|
||||
["search.execute"] = new("search.execute", "interaction", new[] { "resultBucket" }, false),
|
||||
["command.execute"] = new("command.execute", "command", new[] { "commandId", "operationKind" }, false),
|
||||
["command.succeeded"] = new("command.succeeded", "command", new[] { "commandId", "operationKind" }, true),
|
||||
["command.failed"] = new("command.failed", "command", new[] { "commandId", "problemType", "reasonCode" }, true),
|
||||
["lookup.open"] = new("lookup.open", "lookup", new[] { "entityType" }, false),
|
||||
["lookup.select"] = new("lookup.select", "lookup", new[] { "entityType", "selectionSource" }, false),
|
||||
["grid.bulk_action"] = new("grid.bulk_action", "interaction", new[] { "commandId", "countBucket" }, false),
|
||||
["validation.failed"] = new("validation.failed", "quality", new[] { "stage", "errorCountBucket", "reasonCode" }, false),
|
||||
["excel.import.start"] = new("excel.import.start", "excel", new[] { "importType", "rowCountBucket" }, false),
|
||||
["excel.import.completed"] = new("excel.import.completed", "excel", new[] { "importType", "result", "rowCountBucket" }, true),
|
||||
["excel.import.failed"] = new("excel.import.failed", "excel", new[] { "importType", "reasonCode", "rowCountBucket" }, true),
|
||||
["exception.open"] = new("exception.open", "exception", new[] { "exceptionType", "severity" }, false),
|
||||
["exception.resolved"] = new("exception.resolved", "exception", new[] { "exceptionType", "resolutionType" }, true),
|
||||
["ai.proposal.open"] = new("ai.proposal.open", "ai", new[] { "proposalType" }, false),
|
||||
["ai.proposal.accept"] = new("ai.proposal.accept", "ai", new[] { "proposalType" }, false),
|
||||
["ai.proposal.reject"] = new("ai.proposal.reject", "ai", new[] { "proposalType", "reasonCode" }, false),
|
||||
["manual.intervention"] = new("manual.intervention", "outcome", new[] { "workType", "reasonCode", "exceptionType" }, false),
|
||||
["experiment.exposed"] = new("experiment.exposed", "experiment", new[] { "surface" }, false)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.Telemetry;
|
||||
|
||||
public sealed class KbxUxMetricsQuery(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<KbxUxMetricsResponse> ExecuteAsync(Guid tenantId,DateOnly from,DateOnly to,string? screenId,CancellationToken ct)
|
||||
{
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
var args=new{TenantId=tenantId,From=from.ToDateTime(TimeOnly.MinValue,DateTimeKind.Utc),To=to.AddDays(1).ToDateTime(TimeOnly.MinValue,DateTimeKind.Utc),ScreenId=screenId};
|
||||
const string eventsSql="""
|
||||
select event_name as EventName,count(*) as Samples,
|
||||
percentile_cont(0.5) within group(order by duration_ms) filter(where duration_ms is not null) as P50,
|
||||
percentile_cont(0.95) within group(order by duration_ms) filter(where duration_ms is not null) as P95
|
||||
from kbx.ux_events
|
||||
where tenant_id=@TenantId and occurred_at>=@From and occurred_at<@To
|
||||
and (@ScreenId is null or screen_id=@ScreenId)
|
||||
group by event_name;
|
||||
""";
|
||||
var rows=(await connection.QueryAsync<EventAgg>(new CommandDefinition(eventsSql,args,cancellationToken:ct))).ToDictionary(x=>x.EventName,StringComparer.Ordinal);
|
||||
const string outcomeSql="""
|
||||
select coalesce(sum(observed_count),0) Observed,coalesce(sum(manual_intervention_count),0) Manual
|
||||
from kbx.ux_business_outcomes where tenant_id=@TenantId and business_date>=date(@From) and business_date<date(@To) and (@ScreenId is null or screen_id=@ScreenId);
|
||||
""";
|
||||
var outcome=await connection.QuerySingleAsync<OutcomeAgg>(new CommandDefinition(outcomeSql,args,cancellationToken:ct));
|
||||
long Count(string name)=>rows.TryGetValue(name,out var x)?x.Samples:0;
|
||||
double? Ratio(long n,long d)=>d==0?null:Math.Round((double)n*100d/d,2);
|
||||
var metrics=new List<KbxUxMetricRow>{
|
||||
new("task_completion_time_ms","Task Completion Time",rows.GetValueOrDefault("task.complete")?.P50,"ms",Count("task.complete"),rows.GetValueOrDefault("task.complete")?.P50,rows.GetValueOrDefault("task.complete")?.P95),
|
||||
new("semantic_interactions_per_task","Interactions / Task",Count("task.complete")==0?null:Math.Round((double)Count("interaction.execute")/Count("task.complete"),2),"count",Count("task.complete")),
|
||||
new("manual_intervention_rate","Manual Intervention Rate",outcome.Observed==0?null:Math.Round((double)outcome.Manual*100d/outcome.Observed,2),"percent",outcome.Observed),
|
||||
new("validation_failure_rate","Validation Failure Rate",Ratio(Count("validation.failed"),Count("command.execute")),"percent",Count("command.execute")),
|
||||
new("import_failure_rate","Import Failure Rate",Ratio(Count("excel.import.failed"),Count("excel.import.start")),"percent",Count("excel.import.start")),
|
||||
new("exception_resolution_time_ms","Exception Resolution Time",rows.GetValueOrDefault("exception.resolved")?.P50,"ms",Count("exception.resolved"),rows.GetValueOrDefault("exception.resolved")?.P50,rows.GetValueOrDefault("exception.resolved")?.P95),
|
||||
new("ai_proposal_acceptance_rate","AI Proposal Acceptance Rate",Ratio(Count("ai.proposal.accept"),Count("ai.proposal.open")),"percent",Count("ai.proposal.open")),
|
||||
};
|
||||
return new(from,to,screenId,metrics);
|
||||
}
|
||||
private sealed record EventAgg(string EventName,long Samples,double? P50,double? P95);
|
||||
private sealed record OutcomeAgg(long Observed,long Manual);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Text.Json;
|
||||
using Kbx.Shared.Telemetry.Generated;
|
||||
using Kbx.Shared.Experiments.Generated;
|
||||
|
||||
namespace Kbx.Shared.Telemetry;
|
||||
|
||||
public sealed record KbxUxEventRequest(
|
||||
string EventName,
|
||||
string? ScreenId,
|
||||
string? ScreenVersion,
|
||||
Guid SessionId,
|
||||
Guid? TaskSessionId,
|
||||
string? AppVersion,
|
||||
string? ExperimentId,
|
||||
string? ExperimentVariant,
|
||||
DateTimeOffset OccurredAt,
|
||||
int? DurationMs,
|
||||
int? Count,
|
||||
IReadOnlyDictionary<string,string>? Attributes);
|
||||
|
||||
public sealed record KbxUxEventBatchRequest(IReadOnlyList<KbxUxEventRequest> Events);
|
||||
public sealed record KbxUxMetricRow(string MetricKey,string Label,double? Value,string Unit,long SampleCount,double? P50=null,double? P95=null);
|
||||
public sealed record KbxUxMetricsResponse(DateOnly From,DateOnly To,string? ScreenId,IReadOnlyList<KbxUxMetricRow> Metrics);
|
||||
|
||||
public static class KbxUxTelemetryGuard
|
||||
{
|
||||
private static readonly string[] ForbiddenFragments = ["phone","address","name","orderno","customer","itemcode","barcode","keyword","query","email","businessnumber","entityid"];
|
||||
public static void Validate(KbxUxEventRequest item)
|
||||
{
|
||||
if (!KbxTelemetryCatalog.Events.TryGetValue(item.EventName,out var definition)) throw new ArgumentException($"Unknown telemetry event: {item.EventName}");
|
||||
if (definition.RequiresDuration && item.DurationMs is null) throw new ArgumentException($"{item.EventName} requires durationMs.");
|
||||
if (item.DurationMs is < 0 or > 86_400_000) throw new ArgumentOutOfRangeException(nameof(item.DurationMs));
|
||||
if (item.ExperimentId is { Length: > 160 } || item.ExperimentVariant is { Length: > 80 }) throw new ArgumentException("Experiment context is too long.");
|
||||
if ((item.ExperimentId is null) != (item.ExperimentVariant is null)) throw new ArgumentException("Experiment id and variant must be supplied together.");
|
||||
if (item.Count is < 1 or > 1_000_000) throw new ArgumentOutOfRangeException(nameof(item.Count));
|
||||
var allowed=definition.AllowedAttributes.ToHashSet(StringComparer.Ordinal);
|
||||
foreach(var pair in item.Attributes ?? new Dictionary<string,string>())
|
||||
{
|
||||
if(!allowed.Contains(pair.Key)) throw new ArgumentException($"Attribute '{pair.Key}' is not allowed for {item.EventName}.");
|
||||
if(ForbiddenFragments.Any(x=>pair.Key.Contains(x,StringComparison.OrdinalIgnoreCase))) throw new ArgumentException($"Attribute '{pair.Key}' is forbidden for telemetry.");
|
||||
if(pair.Value.Length>80) throw new ArgumentException($"Attribute value is too long: {pair.Key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Kbx.Shared.Experiments;
|
||||
using Kbx.Shared.Experiments.Generated;
|
||||
|
||||
namespace Kbx.Shared.Telemetry;
|
||||
|
||||
public sealed class KbxUxTelemetryRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task AppendAsync(Guid tenantId,Guid userId,IReadOnlyList<KbxUxEventRequest> events,CancellationToken ct)
|
||||
{
|
||||
if(events.Count is 0)return;if(events.Count>100)throw new ArgumentException("Telemetry batch is limited to 100 events.");
|
||||
foreach(var item in events)KbxUxTelemetryGuard.Validate(item);
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
var normalizedEvents=await NormalizeExperimentContextAsync(connection,tenantId,userId,events,ct);
|
||||
await using var tx=await connection.BeginTransactionAsync(ct);
|
||||
const string sql="""
|
||||
insert into kbx.ux_events(id,tenant_id,event_name,screen_id,screen_version,session_id,task_session_id,app_version,experiment_id,experiment_variant,occurred_at,duration_ms,event_count,attributes)
|
||||
values(@Id,@TenantId,@EventName,@ScreenId,@ScreenVersion,@SessionId,@TaskSessionId,@AppVersion,@ExperimentId,@ExperimentVariant,@OccurredAt,@DurationMs,@Count,cast(@Attributes as jsonb));
|
||||
""";
|
||||
foreach(var item in normalizedEvents)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql,new{Id=Guid.NewGuid(),TenantId=tenantId,item.EventName,item.ScreenId,item.ScreenVersion,item.SessionId,item.TaskSessionId,item.AppVersion,item.ExperimentId,item.ExperimentVariant,item.OccurredAt,item.DurationMs,Count=item.Count??1,Attributes=JsonSerializer.Serialize(item.Attributes??new Dictionary<string,string>())},tx,cancellationToken:ct));
|
||||
}
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
private static async Task<IReadOnlyList<KbxUxEventRequest>> NormalizeExperimentContextAsync(NpgsqlConnection connection,Guid tenantId,Guid userId,IReadOnlyList<KbxUxEventRequest> events,CancellationToken ct)
|
||||
{
|
||||
var candidates=events.Where(x=>x.EventName!="experiment.exposed"||x.ExperimentId is not null).ToArray();
|
||||
var ids=candidates.Where(x=>x.ExperimentId is not null).Select(x=>x.ExperimentId!).Distinct(StringComparer.Ordinal).ToArray();
|
||||
if(ids.Length==0)return candidates;
|
||||
const string sql="""
|
||||
select a.experiment_id ExperimentId,a.variant Variant,r.state State,r.rollout_percent RolloutPercent,r.kill_switch KillSwitch
|
||||
from kbx.experiment_assignments a
|
||||
join kbx.experiment_runtime r on r.tenant_id=a.tenant_id and r.experiment_id=a.experiment_id
|
||||
where a.tenant_id=@TenantId and a.user_id=@UserId and a.experiment_id=any(@Ids);
|
||||
""";
|
||||
var rows=(await connection.QueryAsync<ExperimentContextRow>(new CommandDefinition(sql,new{TenantId=tenantId,UserId=userId,Ids=ids},cancellationToken:ct))).ToDictionary(x=>x.ExperimentId,StringComparer.Ordinal);
|
||||
var result=new List<KbxUxEventRequest>(candidates.Length);
|
||||
foreach(var item in candidates)
|
||||
{
|
||||
if(item.ExperimentId is null){result.Add(item);continue;}
|
||||
var valid=KbxExperimentCatalog.Experiments.TryGetValue(item.ExperimentId,out var definition)
|
||||
&& definition.Variants.Any(x=>x.Key==item.ExperimentVariant)
|
||||
&& rows.TryGetValue(item.ExperimentId,out var row)
|
||||
&& row.State=="running"&&!row.KillSwitch&&row.Variant==item.ExperimentVariant
|
||||
&& KbxExperimentAssignmentPolicy.IsEnrolled(tenantId,userId,item.ExperimentId,row.RolloutPercent);
|
||||
if(valid)result.Add(item);
|
||||
else if(item.EventName!="experiment.exposed")result.Add(item with { ExperimentId=null,ExperimentVariant=null });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private sealed record ExperimentContextRow(string ExperimentId,string Variant,string State,int RolloutPercent,bool KillSwitch);
|
||||
|
||||
}
|
||||
|
||||
public sealed class KbxUxBusinessOutcomeRecorder(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task RecordAsync(Guid tenantId,DateOnly businessDate,string? screenId,string workType,int observedCount,int autoProcessedCount,int manualInterventionCount,string? reasonCode,CancellationToken ct)
|
||||
{
|
||||
if(observedCount<0||autoProcessedCount<0||manualInterventionCount<0||autoProcessedCount+manualInterventionCount>observedCount)throw new ArgumentOutOfRangeException(nameof(observedCount));
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
const string sql="""
|
||||
insert into kbx.ux_business_outcomes(tenant_id,business_date,screen_id,work_type,observed_count,auto_processed_count,manual_intervention_count,reason_code,updated_at)
|
||||
values(@TenantId,@BusinessDate,@ScreenId,@WorkType,@Observed,@Auto,@Manual,@Reason,now())
|
||||
on conflict(tenant_id,business_date,screen_id,work_type,reason_code)
|
||||
do update set observed_count=excluded.observed_count,auto_processed_count=excluded.auto_processed_count,manual_intervention_count=excluded.manual_intervention_count,updated_at=now();
|
||||
""";
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql,new{TenantId=tenantId,BusinessDate=businessDate,ScreenId=screenId??string.Empty,WorkType=workType,Observed=observedCount,Auto=autoProcessedCount,Manual=manualInterventionCount,Reason=reasonCode??string.Empty},cancellationToken:ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Dapper;using Npgsql;
|
||||
namespace Kbx.Shared.Telemetry;
|
||||
public sealed class PurgeUxTelemetryJob(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition("delete from kbx.ux_events where occurred_at < now() - interval '90 days';",cancellationToken:ct));
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using Xunit;
|
||||
namespace Kbx.Shared.Telemetry.Tests;
|
||||
public sealed class KbxUxTelemetryGuardTests
|
||||
{
|
||||
[Fact] public void Rejects_unknown_event()=>Assert.Throws<ArgumentException>(()=>KbxUxTelemetryGuard.Validate(new("unknown.event",null,null,Guid.NewGuid(),null,null,null,null,DateTimeOffset.UtcNow,null,1,null)));
|
||||
[Fact] public void Rejects_non_allowlisted_attribute()=>Assert.Throws<ArgumentException>(()=>KbxUxTelemetryGuard.Validate(new("screen.open","OMS-ORD-001","1.0.0",Guid.NewGuid(),null,null,null,null,DateTimeOffset.UtcNow,null,1,new Dictionary<string,string>{{"phone","01012345678"}})));
|
||||
}
|
||||
Reference in New Issue
Block a user