diff --git a/src/FS.GG.Coord.Cli/TelemetryStoreApplication.fs b/src/FS.GG.Coord.Cli/TelemetryStoreApplication.fs index 2114ccc34..2d0e70d7b 100644 --- a/src/FS.GG.Coord.Cli/TelemetryStoreApplication.fs +++ b/src/FS.GG.Coord.Cli/TelemetryStoreApplication.fs @@ -7529,8 +7529,44 @@ WHERE n.source_ref=$source; if Encoding.UTF8.GetByteCount encoded > 4 * 1024 * 1024 then invalidOp "efficiency-export-byte-bound" encoded + // Same authenticated workspace/read transaction as the item snapshot. Raw + // native identities and source metadata never enter this display allowlist. + let private passiveDashboardRows (connection: SqliteConnection) (transaction: SqliteTransaction) = + use command = connection.CreateCommand() + command.Transaction <- transaction + command.CommandText <- """ +SELECT f.canonical,t.accepted_at FROM current_ingest_facts f +JOIN fact_admissions a ON a.identity=f.identity AND a.authority_role='generic' +JOIN fact_acceptance_times t ON t.identity=f.identity AND t.fact_revision=f.revision AND t.content_digest=f.content_digest +JOIN receipt_admissions r ON r.receipt_key=t.receipt_key AND r.producer=a.producer AND r.stream=a.stream AND r.authority_role='generic' +JOIN transport_receipts tr ON tr.producer=r.producer AND tr.batch=r.batch AND tr.digest=r.envelope_digest AND tr.state='applied' +WHERE f.kind='provider-usage-observation/1' AND f.item_id IS NULL +ORDER BY f.identity LIMIT 257; +""" + use reader = command.ExecuteReader() + let observations = JsonArray() + let mutable truncated = false + while reader.Read() do + if observations.Count = 256 then truncated <- true + else + let canonical = reader.GetString 0 + if Encoding.UTF8.GetByteCount canonical > 16384 then invalidOp "passive-display-row-byte-bound" + use document = JsonDocument.Parse canonical + let row = JsonObject() + for name in ["revision";"capturedAt";"occurredAt";"input";"cachedInput";"cacheWriteInput";"output";"reasoning";"total"] do + row[name] <- JsonNode.Parse(document.RootElement.GetProperty(name).GetRawText()) + row["receivedAt"] <- JsonValue.Create(reader.GetString 1) + observations.Add row + let result = JsonObject() + result["schema"] <- JsonValue.Create "fsgg.telemetry.passive-display/1" + result["limit"] <- JsonValue.Create 256 + result["truncated"] <- JsonValue.Create truncated + result["observations"] <- observations + result + let private dashboardSnapshotBound compactCi + includePassive path assessment (workspaceId: (string * (unit -> unit)) option) @@ -7703,6 +7739,8 @@ WHERE n.source_ref=$source; journalMode = journal |} + if includePassive then content["passiveUsage"] <- passiveDashboardRows connection transaction + content["items"] <- arrayOfStrings selectedItems content["summaries"] <- summaries @@ -7776,7 +7814,7 @@ WHERE n.source_ref=$source; let revision = CanonicalJson.sha256 canonicalBytes let envelope = JsonObject() - envelope["schema"] <- JsonValue.Create(if compactCi then "fsgg.telemetry.item-detail/3" else "fsgg.telemetry.item-detail/2") + envelope["schema"] <- JsonValue.Create(if includePassive then "fsgg.telemetry.item-detail/4" elif compactCi then "fsgg.telemetry.item-detail/3" else "fsgg.telemetry.item-detail/2") envelope["workspaceId"] <- JsonValue.Create(snapshotWorkspace) envelope["observedAt"] <- JsonValue.Create(DateTimeOffset.UtcNow.ToString("O")) envelope["revision"] <- JsonValue.Create(revision) @@ -7826,11 +7864,11 @@ WHERE n.source_ref=$source; Error [ error.Message ] let efficiencyExport path assessment expectedRevision maxItems maxMetrics = - dashboardSnapshotBound true path assessment None ignore None + dashboardSnapshotBound true false path assessment None ignore None (Some(efficiencyExportInSnapshot expectedRevision maxItems maxMetrics)) let efficiencyAnalysisInspect path assessment requestId = - dashboardSnapshotBound true path assessment None ignore None (Some(fun connection transaction baseSnapshot revision -> + dashboardSnapshotBound true false path assessment None ignore None (Some(fun connection transaction baseSnapshot revision -> let record,packet,_,_,_,_ = efficiencyQueueRow connection requestId record["packetBase64"] <- JsonValue.Create(Convert.ToBase64String packet) record["rawDigest"] <- JsonValue.Create("sha256:"+CanonicalJson.sha256 packet) @@ -7876,19 +7914,19 @@ WHERE n.source_ref=$source; efficiencyQueueResult record)) let dashboardSnapshotWithHooks path assessment (hooks: DashboardSnapshotHooks) itemId = - dashboardSnapshotBound false path assessment None hooks.AfterFirstRead itemId None + dashboardSnapshotBound false false path assessment None hooks.AfterFirstRead itemId None let dashboardSnapshot path assessment itemId = dashboardSnapshotWithHooks path assessment ({ AfterFirstRead = ignore }: DashboardSnapshotHooks) itemId let compactDashboardSnapshotWithHooks path assessment (hooks: DashboardSnapshotHooks) itemId = - dashboardSnapshotBound true path assessment None hooks.AfterFirstRead itemId None + dashboardSnapshotBound true false path assessment None hooks.AfterFirstRead itemId None let compactDashboardSnapshot path assessment itemId = compactDashboardSnapshotWithHooks path assessment ({ AfterFirstRead = ignore }: DashboardSnapshotHooks) itemId let scopedDashboardSnapshotWithHooks path assessment workspaceId (hooks: ScopedDashboardSnapshotHooks) itemId = - dashboardSnapshotBound false path assessment (Some(workspaceId, hooks.ReceiptKeyComputed)) hooks.AfterFirstRead itemId None + dashboardSnapshotBound false false path assessment (Some(workspaceId, hooks.ReceiptKeyComputed)) hooks.AfterFirstRead itemId None let scopedDashboardSnapshot path assessment workspaceId itemId = scopedDashboardSnapshotWithHooks @@ -7901,6 +7939,10 @@ WHERE n.source_ref=$source; } itemId + // Workspace-authorized current passive facts, sharing the item snapshot transaction. + let scopedDashboardSnapshotWithPassive path assessment workspaceId itemId = + dashboardSnapshotBound false true path assessment (Some(workspaceId, ignore)) ignore itemId None + type private ActivationRow = { Id: string diff --git a/src/FS.GG.Coord.Cli/TelemetryStoreApplication.fsi b/src/FS.GG.Coord.Cli/TelemetryStoreApplication.fsi index d2e9ccc64..4635ebe87 100644 --- a/src/FS.GG.Coord.Cli/TelemetryStoreApplication.fsi +++ b/src/FS.GG.Coord.Cli/TelemetryStoreApplication.fsi @@ -365,6 +365,11 @@ module TelemetryStoreApplication = itemId: string option -> Result + /// Workspace-authorized item and bounded unassigned usage snapshot from one read transaction. + val scopedDashboardSnapshotWithPassive: + path: string -> assessment: TelemetryStore.DurabilityAssessment -> workspaceId: string -> itemId: string option -> + Result + val exportPublic: path: string -> assessment: TelemetryStore.DurabilityAssessment -> diff --git a/src/FS.GG.Telemetry.Dashboard/Assets/app.js b/src/FS.GG.Telemetry.Dashboard/Assets/app.js index f8dfc146e..0969d91d8 100644 --- a/src/FS.GG.Telemetry.Dashboard/Assets/app.js +++ b/src/FS.GG.Telemetry.Dashboard/Assets/app.js @@ -112,6 +112,25 @@ } return section; }; + const passiveCounters = ["input","cachedInput","cacheWriteInput","output","reasoning","total"]; + function renderPassive(data){ + const target=$("unassigned-rows");target.replaceChildren(); + if(data.schema!=="fsgg.telemetry.private-dashboard/3"){ + text($("unassigned-status"),"Unassigned usage projection unavailable in this snapshot.");return; + } + const usage=data.unassignedUsage; + text($("unassigned-status"),`${usage.observations.length} displayed observations · partial population coverage · item attribution and billed cost: unknown.${usage.truncated?` Display limited to ${usage.limit} observations.`:""} Counters are shown per observation without a combined total.`); + if(!usage.observations.length)target.append(element("li","No receiver-applied unassigned observations are available in this bounded snapshot.")); + const counter=value=>value===null?"unknown":BigInt(value).toLocaleString(); + for(const row of usage.observations){ + const item=element("li","","passive-row"); + item.append(element("h3",`Observation ${row.ordinal} · revision ${row.revision}`)); + item.append(element("p",`Input ${counter(row.input)} · cached input ${counter(row.cachedInput)} · cache writes ${counter(row.cacheWriteInput)} · output ${counter(row.output)} · reasoning ${counter(row.reasoning)}`)); + item.append(element("p",`Observed total: ${counter(row.total)}`,"passive-total")); + item.append(element("p",`Captured ${row.capturedAt} · received ${row.receivedAt} · occurred ${row.occurredAt??"unknown"}`,"passive-clocks")); + target.append(item); + } + } function renderOverview(data){ text($("process-verdict"),"Insufficient evidence"); text($("process-reason"),"Health requires delivery, rework, coordination, cost and reliability evidence. This snapshot does not supply a diagnostic assessment or comparable baseline."); @@ -137,6 +156,7 @@ } function render(data) { renderOverview(data); + renderPassive(data); text($("freshness"),data.observedAt?`Snapshot read ${new Date(data.observedAt).toLocaleString()} · source freshness unestablished`:"Observation time unavailable"); const receipts=data.operational.appliedReceipts==null||data.operational.rejectedReceipts==null?"receipt outcomes unavailable":`${data.operational.appliedReceipts} applied receipts · ${data.operational.rejectedReceipts} rejected receipts`; text($("health"),`${count(data.operational.pendingBatches)} pending receipts · ${receipts} · ${data.operational.consistency}`); @@ -170,7 +190,7 @@ } target.append(card); }); - if(!data.items.length)target.append(element("p","No scoped observations are available.")); + if(!data.items.length)target.append(element("p","No attributed item observations are available.")); applySelection(); } const object = value => value !== null && typeof value === "object" && !Array.isArray(value); @@ -205,12 +225,22 @@ && (!item.steps || (object(item.steps) && ["runtimeCount","activityCount","ciStepCount","limitPerKind"].every(name=>Number.isSafeInteger(item.steps[name])&&item.steps[name]>=0) && typeof item.steps.truncated==="boolean" && Array.isArray(item.steps.rows) && item.steps.rows.length<=192 && item.steps.rows.every(validStep))) && validHealth(item.processHealth); - const validSnapshot = (data, workspace) => object(data) && data.schema==="fsgg.telemetry.private-dashboard/2" + const exactKeys = (value,names) => object(value) && Object.keys(value).length===names.length && names.every(name=>Object.hasOwn(value,name)); + const decimal = value => typeof value==="string" && /^(0|[1-9][0-9]{0,18})$/.test(value) && BigInt(value)<=9223372036854775807n; + const validPassive = usage => exactKeys(usage,["schema","populationCoverage","attribution","cost","counterEncoding","limit","truncated","observations"]) + && usage.schema==="fsgg.telemetry.unassigned-usage/1" && usage.populationCoverage==="partial" && usage.attribution==="unknown" && usage.cost==="unknown" + && usage.counterEncoding==="decimal-int64" && usage.limit===256 && typeof usage.truncated==="boolean" && Array.isArray(usage.observations) && usage.observations.length<=256 + && usage.observations.every((row,index)=>exactKeys(row,["ordinal","revision","capturedAt","occurredAt","receivedAt",...passiveCounters]) + && row.ordinal===index+1 && decimal(row.revision) && row.revision!=="0" && row.capturedAt!==null && row.receivedAt!==null + && ["capturedAt","occurredAt","receivedAt"].every(name=>timestamp(row[name])) && passiveCounters.every(name=>row[name]===null||decimal(row[name]))); + const validSnapshot = (data, workspace) => object(data) && ["fsgg.telemetry.private-dashboard/2","fsgg.telemetry.private-dashboard/3"].includes(data.schema) + && (data.schema!=="fsgg.telemetry.private-dashboard/3"||validPassive(data.unassignedUsage)) && data.workspaceId===workspace && string(data.revision,64) && /^[0-9a-f]{64}$/.test(data.revision) && timestamp(data.observedAt) && object(data.processHealth) && data.processHealth.schema==="fsgg.telemetry.process-health/1" && data.processHealth.verdict==="insufficient-evidence" && object(data.operational) && ["pendingBatches","appliedReceipts","rejectedReceipts"].every(name=>quantity(data.operational[name])) && string(data.operational.consistency) && Array.isArray(data.items) && data.items.length<=200 && data.items.every(validItem) && new Set(data.items.map(item=>item.id)).size===data.items.length; function clearSnapshot(){ + $("unassigned-rows").replaceChildren();text($("unassigned-status"),""); lastValid=null;selectedOutcome="";openOutcomes.clear();openEvidence.clear();$("window").value="24h";$("items").replaceChildren();$("categories").replaceChildren(); const option=element("option","All recorded outcomes");option.value="";$("outcome").replaceChildren(option); for(const id of ["freshness","health","process-verdict","process-reason","process-coverage","window-note"])text($(id),""); diff --git a/src/FS.GG.Telemetry.Dashboard/Assets/index.html b/src/FS.GG.Telemetry.Dashboard/Assets/index.html index 0f32c9629..dc7d9e87f 100644 --- a/src/FS.GG.Telemetry.Dashboard/Assets/index.html +++ b/src/FS.GG.Telemetry.Dashboard/Assets/index.html @@ -2,5 +2,5 @@ FS-GG private telemetry

FS-GG

Process health

Sign in

- +
diff --git a/src/FS.GG.Telemetry.Dashboard/DashboardProjection.fs b/src/FS.GG.Telemetry.Dashboard/DashboardProjection.fs index f09346c2e..bc3632c80 100644 --- a/src/FS.GG.Telemetry.Dashboard/DashboardProjection.fs +++ b/src/FS.GG.Telemetry.Dashboard/DashboardProjection.fs @@ -619,6 +619,56 @@ module DashboardProjection = Ok(node :> JsonNode) | _ -> Error InvalidSnapshot + let private projectPassive (value: JsonElement) = + let require condition = if not condition then invalidOp "invalid passive display snapshot" + require (exactNames value (set ["schema";"limit";"truncated";"observations"]) + && text "schema" value = Some "fsgg.telemetry.passive-display/1" + && number "limit" value = Some 256L) + let truncated = value.GetProperty "truncated" + require (truncated.ValueKind = JsonValueKind.True || truncated.ValueKind = JsonValueKind.False) + let observations = value.GetProperty "observations" + require (observations.ValueKind = JsonValueKind.Array && observations.GetArrayLength() <= 256) + let counters = ["input";"cachedInput";"cacheWriteInput";"output";"reasoning";"total"] + let names = Set.ofList (["revision";"capturedAt";"occurredAt";"receivedAt"] @ counters) + let rows = JsonArray() + for row in observations.EnumerateArray() do + require (exactNames row names) + let projected = JsonObject() + projected["ordinal"] <- JsonValue.Create(rows.Count + 1) + let revision = number "revision" row |> Option.filter (fun n -> n > 0L) + require revision.IsSome + projected["revision"] <- JsonValue.Create(revision.Value.ToString(Globalization.CultureInfo.InvariantCulture)) + for name in counters do + match nullableNumber name row with + | Some(Some n) -> projected[name] <- JsonValue.Create(n.ToString(Globalization.CultureInfo.InvariantCulture)) + | Some None -> projected[name] <- null + | None -> invalidOp "invalid passive display counter" + let counter name = nullableNumber name row |> Option.defaultWith (fun () -> invalidOp "invalid passive display counter") + let subset part whole = match counter part, counter whole with Some p, Some w -> p <= w | _ -> true + require (counters |> List.exists (counter >> Option.isSome)) + require (subset "cachedInput" "input" && subset "cacheWriteInput" "input" && subset "reasoning" "output") + match counter "input", counter "output", counter "total" with + | Some input, Some output, Some total -> require (input <= Int64.MaxValue - output && input + output = total) + | _ -> () + for name in ["capturedAt";"occurredAt";"receivedAt"] do + let node = row.GetProperty name + if name = "occurredAt" && node.ValueKind = JsonValueKind.Null then projected[name] <- null + else + let timestamp = text name row |> safeTime + require timestamp.IsSome + projected[name] <- JsonValue.Create timestamp.Value + rows.Add projected + let result = JsonObject() + result["schema"] <- JsonValue.Create "fsgg.telemetry.unassigned-usage/1" + result["populationCoverage"] <- JsonValue.Create "partial" + result["attribution"] <- JsonValue.Create "unknown" + result["cost"] <- JsonValue.Create "unknown" + result["counterEncoding"] <- JsonValue.Create "decimal-int64" + result["limit"] <- JsonValue.Create 256 + result["truncated"] <- JsonValue.Create(truncated.GetBoolean()) + result["observations"] <- rows + result + let project (authorizedWorkspace: string) (canonicalSnapshotEnvelopeBytes: byte array) = let bytes = canonicalSnapshotEnvelopeBytes @@ -650,7 +700,7 @@ module DashboardProjection = if not (exactNames envelope names) then Error InvalidEnvelope - elif text "schema" envelope <> Some "fsgg.telemetry.item-detail/2" then + elif not (Set.contains (text "schema" envelope) (set [Some "fsgg.telemetry.item-detail/2";Some "fsgg.telemetry.item-detail/4"])) then Error UnsupportedSchema elif text "workspaceId" envelope <> Some authorizedWorkspace then Error UnauthorizedWorkspace @@ -751,6 +801,8 @@ module DashboardProjection = "reviews" "learningObservations"] + let withPassive = text "schema" envelope = Some "fsgg.telemetry.item-detail/4" + let expected = if withPassive then Set.add "passiveUsage" expected else expected let supportedShape = exactNames snapshot (Set.add "responseUsage" expected) && text "learningSnapshotSchema" snapshot = Some "fsgg.telemetry.learn-item-detail/4" @@ -880,7 +932,8 @@ module DashboardProjection = | Some error -> Error error | None -> let result = JsonObject() - result["schema"] <- Schema + result["schema"] <- (if withPassive then "fsgg.telemetry.private-dashboard/3" else Schema) + if withPassive then result["unassignedUsage"] <- projectPassive (snapshot.GetProperty "passiveUsage") result["workspaceId"] <- authorizedWorkspace result["observedAt"] <- observedAt.Value result["revision"] <- revision diff --git a/src/FS.GG.Telemetry.Host/Program.fs b/src/FS.GG.Telemetry.Host/Program.fs index 8db63828b..74dba6568 100644 --- a/src/FS.GG.Telemetry.Host/Program.fs +++ b/src/FS.GG.Telemetry.Host/Program.fs @@ -21,7 +21,7 @@ open FS.GG.Coord.Cli open FS.GG.Coord.GitHub open FS.GG.Telemetry.Dashboard -module private BrowserComposition = +module internal BrowserComposition = let options config = { PublicOrigin = Uri config.ListenUrl @@ -34,14 +34,14 @@ module private BrowserComposition = QueryTimeout = TimeSpan.FromSeconds(float config.BrowserSession.QueryTimeoutSeconds) } - let snapshot config workspace itemId = + let snapshotWithAssessment config assessmentFor workspace itemId = match config.Stores |> Array.tryFind (fun store -> store.WorkspaceId = workspace) with | None -> Error [ "projection-unavailable" ] | Some store -> match - TelemetryStoreApplication.scopedDashboardSnapshot + TelemetryStoreApplication.scopedDashboardSnapshotWithPassive store.Root - (TelemetryStoreApplication.assessProductionRoot store.Root) + (assessmentFor store.Root) workspace itemId with @@ -51,6 +51,9 @@ module private BrowserComposition = | Ok bytes -> Ok bytes | Error _ -> Error [ "projection-unavailable" ] + let snapshot config workspace itemId = + snapshotWithAssessment config TelemetryStoreApplication.assessProductionRoot workspace itemId + module Hosting = let createBuilder (listenUrl: string) certificate = let builder = WebApplication.CreateBuilder(WebApplicationOptions(Args = [||])) diff --git a/src/FS.GG.Telemetry.Host/Program.fsi b/src/FS.GG.Telemetry.Host/Program.fsi index eb978f232..6e05dff1b 100644 --- a/src/FS.GG.Telemetry.Host/Program.fsi +++ b/src/FS.GG.Telemetry.Host/Program.fsi @@ -4,6 +4,12 @@ open System.Collections.Generic open System.Security.Cryptography.X509Certificates open Microsoft.AspNetCore.Builder +module internal BrowserComposition = + val snapshotWithAssessment: + config: HostConfig -> assessmentFor: (string -> FS.GG.Coord.TelemetryStore.DurabilityAssessment) -> + workspace: string -> itemId: string option -> Result + val snapshot: config: HostConfig -> workspace: string -> itemId: string option -> Result + module Hosting = val createBuilder: listenUrl: string -> certificate: X509Certificate2 -> WebApplicationBuilder diff --git a/src/FS.GG.Telemetry.Host/README.md b/src/FS.GG.Telemetry.Host/README.md index adb433182..6d51d9bf9 100644 --- a/src/FS.GG.Telemetry.Host/README.md +++ b/src/FS.GG.Telemetry.Host/README.md @@ -45,3 +45,19 @@ validated input hashes. Source verification, snapshot origin and shared-cost completeness remain `unknown`; capture qualification, native acceptance and activation remain false. This source addition requires a protected successor publication before served-code qualification; Host 0.3.0 does not contain it. + +## Unassigned usage browser projection + +The prepared browser composition reads item facts and up to 256 current, +authenticated unassigned passive observations in one workspace-authorized +transaction. Its item-detail/4 input produces private-dashboard/3 with an +allowlisted unassigned-usage/1 section. Existing item-detail/2 projection and +private-dashboard/2 consumers retain their format; the updated assets read both +versions. Raw native identities, source paths and payloads are excluded. + +Counters remain per observation, with exact decimal-int64 strings and nullable +missing values. Capture, occurrence and receiver clocks stay separate. Coverage +is partial; item attribution and billed cost are unknown. These observations do +not enter item totals, and the display does not aggregate cumulative snapshots. +Source qualification does not publish a successor, change the frozen coherent +0.107 or Host 0.7 artifacts, or establish installed/native receiver acceptance. diff --git a/tests/FS.GG.Telemetry.Dashboard.Tests/DashboardProjectionTests.fs b/tests/FS.GG.Telemetry.Dashboard.Tests/DashboardProjectionTests.fs index 27731c3b0..bdcd926d8 100644 --- a/tests/FS.GG.Telemetry.Dashboard.Tests/DashboardProjectionTests.fs +++ b/tests/FS.GG.Telemetry.Dashboard.Tests/DashboardProjectionTests.fs @@ -824,3 +824,70 @@ module DashboardProjectionTests = let item = result.RootElement.GetProperty("items").[0] Assert.Equal(JsonValueKind.Null, item.GetProperty("steps").GetProperty("rows").[0].GetProperty("id").ValueKind) Assert.Equal(0, item.GetProperty("processHealth").GetProperty("edges").GetArrayLength()) + let private passiveRow () = + row """{"revision":2,"capturedAt":"2026-10-18T12:00:00Z","occurredAt":null,"receivedAt":"2026-10-18T12:01:00Z","input":9007199254740993,"cachedInput":null,"cacheWriteInput":0,"output":2,"reasoning":null,"total":9007199254740995}""" + + let private passiveSnapshot count = + let value = snapshot "item-a" + let passive = JsonObject() + passive["schema"] <- "fsgg.telemetry.passive-display/1" + passive["limit"] <- 256 + passive["truncated"] <- (count = 256) + passive["observations"] <- JsonArray(Array.init count (fun _ -> passiveRow ())) + value["passiveUsage"] <- passive + value + + let private passiveEnvelope value = + let encoded = JsonNode.Parse(envelope value) + encoded["schema"] <- "fsgg.telemetry.item-detail/4" + JsonSerializer.SerializeToUtf8Bytes encoded + + [] + let ``passive display preserves exact optional counters and observation clocks outside item totals`` () = + use projected = DashboardProjection.project "workspace-a" (passiveEnvelope (passiveSnapshot 1)) |> unwrap |> JsonDocument.Parse + let root = projected.RootElement + Assert.Equal("fsgg.telemetry.private-dashboard/3", root.GetProperty("schema").GetString()) + let usage = root.GetProperty "unassignedUsage" + Assert.Equal("partial", usage.GetProperty("populationCoverage").GetString()) + Assert.Equal("unknown", usage.GetProperty("attribution").GetString()) + Assert.Equal("unknown", usage.GetProperty("cost").GetString()) + let observation = usage.GetProperty("observations").[0] + Assert.Equal("9007199254740995", observation.GetProperty("total").GetString()) + Assert.Equal("0", observation.GetProperty("cacheWriteInput").GetString()) + Assert.Equal(JsonValueKind.Null, observation.GetProperty("cachedInput").ValueKind) + Assert.Equal(JsonValueKind.Null, observation.GetProperty("occurredAt").ValueKind) + Assert.Equal("2", observation.GetProperty("revision").GetString()) + Assert.Equal(1, observation.GetProperty("ordinal").GetInt32()) + Assert.Equal("2026-10-18T12:01:00.0000000+00:00", observation.GetProperty("receivedAt").GetString()) + Assert.Equal(6L, root.GetProperty("items").[0].GetProperty("usage").GetProperty("total").GetInt64()) + + [] + let ``passive extension rejects private fields malformed counters clocks and unsupported bounds`` () = + let mutations: (JsonNode -> unit) list = [ + (fun value -> value["passiveUsage"].["observations"].[0]["threadId"] <- "PRIVATE-NATIVE-THREAD") + (fun value -> value["passiveUsage"].["observations"].[0]["total"] <- -1) + (fun value -> value["passiveUsage"].["observations"].[0]["total"] <- 1) + (fun value -> value["passiveUsage"].["observations"].[0]["cachedInput"] <- Int64.MaxValue) + (fun value -> value["passiveUsage"].["observations"].[0]["capturedAt"] <- "invalid") + (fun value -> value["passiveUsage"].["observations"].[0].AsObject().Remove("input") |> ignore) + (fun value -> value["passiveUsage"].["limit"] <- 257) + (fun value -> value["passiveUsage"].["truncated"] <- "unknown") ] + for mutate in mutations do + let value = passiveSnapshot 1 + mutate value + Assert.True(DashboardProjection.project "workspace-a" (passiveEnvelope value) |> Result.isError) + Assert.True(DashboardProjection.project "workspace-a" (passiveEnvelope (passiveSnapshot 257)) |> Result.isError) + Assert.True(DashboardProjection.project "workspace-b" (passiveEnvelope (passiveSnapshot 1)) |> Result.isError) + Assert.True(DashboardProjection.project "workspace-a" (envelope (passiveSnapshot 1)) |> Result.isError) + + [] + let ``bounded passive view distinguishes an empty partial selection from unavailable legacy projection`` () = + for count in [0;256] do + use projected = DashboardProjection.project "workspace-a" (passiveEnvelope (passiveSnapshot count)) |> unwrap |> JsonDocument.Parse + let usage = projected.RootElement.GetProperty "unassignedUsage" + Assert.Equal(count, usage.GetProperty("observations").GetArrayLength()) + Assert.Equal((count = 256), usage.GetProperty("truncated").GetBoolean()) + Assert.Equal("partial", usage.GetProperty("populationCoverage").GetString()) + use legacy = DashboardProjection.project "workspace-a" (envelope (snapshot "item-a")) |> unwrap |> JsonDocument.Parse + Assert.Equal("fsgg.telemetry.private-dashboard/2", legacy.RootElement.GetProperty("schema").GetString()) + Assert.DoesNotContain("unassignedUsage", legacy.RootElement.GetRawText()) diff --git a/tests/FS.GG.Telemetry.Dashboard.Tests/browser.spec.js b/tests/FS.GG.Telemetry.Dashboard.Tests/browser.spec.js index fd672c82e..5e45ce450 100644 --- a/tests/FS.GG.Telemetry.Dashboard.Tests/browser.spec.js +++ b/tests/FS.GG.Telemetry.Dashboard.Tests/browser.spec.js @@ -131,3 +131,75 @@ test("process overview abstains, preserves selected refresh, and distinguishes h await page.locator(".process-detail summary").click();await page.locator("#refresh").click(); await expect(page.locator(".process-detail")).toHaveAttribute("open",""); }); + +const passiveObservation = ordinal => ({ordinal,revision:"2",capturedAt:"2026-10-18T12:00:00Z",occurredAt:null,receivedAt:"2026-10-18T12:01:00Z",input:"9007199254740993",cachedInput:null,cacheWriteInput:"0",output:"2",reasoning:null,total:"9007199254740995"}); +const passiveSnapshot = count => ({...snapshot("workspace-a"),schema:"fsgg.telemetry.private-dashboard/3",unassignedUsage:{schema:"fsgg.telemetry.unassigned-usage/1",populationCoverage:"partial",attribution:"unknown",cost:"unknown",counterEncoding:"decimal-int64",limit:256,truncated:count===256,observations:Array.from({length:count},(_,index)=>passiveObservation(index+1))}}); +async function passiveTransport(page, current){ + let requests=0; + await page.route("https://telemetry.test/private/dashboard/**",async route=>{ + const request=route.request(),url=new URL(request.url()); + if(request.method()==="GET"){ + const file=url.pathname.endsWith("app.js")?"app.js":url.pathname.endsWith("styles.css")?"styles.css":"index.html"; + return route.fulfill({status:200,contentType:file.endsWith(".js")?"text/javascript":file.endsWith(".css")?"text/css":"text/html",body:fs.readFileSync(path.join(assets,file))}); + } + if(url.pathname.endsWith("/session/refresh"))return route.fulfill({status:200,json:session}); + if(url.pathname.endsWith("/snapshot")){ + requests++; + if(request.postDataJSON().workspaceId!=="workspace-a")return route.fulfill({status:404,json:{code:"not-found"}}); + return route.fulfill({status:200,json:current()}); + } + return route.fulfill({status:204}); + }); + return ()=>requests; +} + +test("unassigned observations keep exact large counters, unknowns and clocks separate from item totals",async({page})=>{ + const data=passiveSnapshot(1); + await passiveTransport(page,()=>data); + await page.goto("https://telemetry.test/private/dashboard/"); + await expect(page.locator(".passive-row")).toHaveCount(1); + await expect(page.locator("#unassigned-status")).toContainText("partial population coverage · item attribution and billed cost: unknown"); + await expect(page.locator(".passive-total")).toHaveText("Observed total: 9,007,199,254,740,995"); + await expect(page.locator(".passive-row")).toContainText("cached input unknown · cache writes 0"); + await expect(page.locator(".passive-clocks")).toContainText("Captured 2026-10-18T12:00:00Z · received 2026-10-18T12:01:00Z · occurred unknown"); + await expect(page.locator("#items .item-token")).toHaveText("Native tokens unavailable"); + await page.locator("#workspace").selectOption("workspace-b"); + await expect(page.locator("#error")).toContainText("selected workspace is unavailable"); + await expect(page.locator("#unassigned-rows")).toBeEmpty(); + await expect(page.locator("#unassigned-status")).toBeEmpty(); +}); + +test("unassigned display is bounded at 256 rows and refuses malformed replacement snapshots",async({page})=>{ + let data=passiveSnapshot(256); + const requests=await passiveTransport(page,()=>data); + await page.goto("https://telemetry.test/private/dashboard/"); + await expect(page.locator(".passive-row")).toHaveCount(256); + await expect(page.locator("#unassigned-status")).toContainText("Display limited to 256 observations"); + expect(requests()).toBe(1); + test.info().annotations.push({type:"structural-workload",description:"256 passive rows; one snapshot request; no cross-row counter aggregation"}); + for(const mutate of [ + value=>{value.unassignedUsage.observations[0].total="9223372036854775808";}, + value=>{value.unassignedUsage.observations[0].cachedInput=0;}, + value=>{value.unassignedUsage.observations[0].threadId="private-native-identity";}, + value=>{value.unassignedUsage.observations[0].capturedAt=null;}, + value=>{value.unassignedUsage.observations.push(passiveObservation(257));} + ]){ + data=passiveSnapshot(256);mutate(data); + await page.locator("#refresh").click(); + await expect(page.locator("#error")).toContainText("Malformed private snapshot"); + await expect(page.locator(".passive-row")).toHaveCount(256); + await expect(page.locator("body")).not.toContainText("private-native-identity"); + } +}); + +test("empty partial observations and legacy unavailable projection remain distinct",async({page})=>{ + let data=passiveSnapshot(0); + await passiveTransport(page,()=>data); + await page.goto("https://telemetry.test/private/dashboard/"); + await expect(page.locator("#unassigned-status")).toContainText("0 displayed observations · partial population coverage"); + await expect(page.locator("#unassigned-rows")).toContainText("No receiver-applied unassigned observations"); + data=snapshot("workspace-a"); + await page.locator("#refresh").click(); + await expect(page.locator("#unassigned-status")).toHaveText("Unassigned usage projection unavailable in this snapshot."); + await expect(page.locator("#unassigned-rows")).toBeEmpty(); +}); diff --git a/tests/FS.GG.Telemetry.Tests/HostPassiveReceiptTests.fs b/tests/FS.GG.Telemetry.Tests/HostPassiveReceiptTests.fs index d0ab52051..736b09595 100644 --- a/tests/FS.GG.Telemetry.Tests/HostPassiveReceiptTests.fs +++ b/tests/FS.GG.Telemetry.Tests/HostPassiveReceiptTests.fs @@ -68,6 +68,16 @@ module HostPassiveReceiptTests = state.StartDrain() let app = (Hosting.createBuilder config.ListenUrl certificate).Build() Endpoints.configure app state (Configuration.credentials config) + let browserAccess = Convert.ToBase64String(Array.create 32 7uy).TrimEnd('=').Replace('+','-').Replace('/','_') + let browserDigest = Convert.ToHexString(SHA256.HashData(Array.create 32 7uy)).ToLowerInvariant() + let browserPrincipal: BrowserPrincipalConfig = + { PrincipalId = "passive-reader"; KeyHashFile = privateFile "browser-key.json" ($"""{{"schema":"fsgg.telemetry.browser-key/1","algorithm":"sha256","keyHash":"{browserDigest}"}}""") + WorkspaceIds = [| owner.Workspace |]; Revoked = false } + let browserOptions: BrowserOptions = + { PublicOrigin = Uri config.ListenUrl; IdleLifetime = TimeSpan.FromMinutes 5.; AbsoluteLifetime = TimeSpan.FromHours 1. + MaximumSessions = 8; LoginAttemptsPerMinute = 32; LoginAdmission = 4; QueryAdmission = 4; QueryTimeout = TimeSpan.FromSeconds 2. } + use browserSecurity = new BrowserSecurity.Service(browserOptions, [| browserPrincipal |]) + BrowserEndpoints.map app browserOptions browserSecurity (BrowserComposition.snapshotWithAssessment config (fun _ -> approved)) try do! app.StartAsync() use handler = new HttpClientHandler(AllowAutoRedirect = false) @@ -148,6 +158,37 @@ module HostPassiveReceiptTests = Assert.Null(current["model"]) Assert.False(current.AsObject().ContainsKey "cost") Assert.Equal("partial", snapshot["populationCoverage"].GetValue()) + let browserRequest route body session = + task { + use message = new HttpRequestMessage(HttpMethod.Post, config.ListenUrl + route) + message.Headers.Add("Origin", config.ListenUrl) + session |> Option.iter (fun value -> message.Headers.Add("Cookie", "__Host-fsgg_session=" + value)) + message.Content <- new StringContent(body, Encoding.UTF8, "application/json") + use! response = client.SendAsync message + let! bytes = response.Content.ReadAsByteArrayAsync() + let cookie = if response.Headers.Contains("Set-Cookie") then response.Headers.GetValues("Set-Cookie") |> Seq.head |> Some else None + return response.StatusCode, bytes, cookie + } + let! anonymous, _, _ = browserRequest "/private/dashboard/v1/snapshot" ($"""{{"workspaceId":"{owner.Workspace}"}}""") None + Assert.Equal(HttpStatusCode.Unauthorized, anonymous) + let! loggedIn, _, cookie = browserRequest "/private/dashboard/login" ($"""{{"principalId":"passive-reader","accessKey":"{browserAccess}"}}""") None + Assert.Equal(HttpStatusCode.OK, loggedIn) + let session = cookie.Value.Split(';').[0].Substring("__Host-fsgg_session=".Length) + let! displayed, bytes, _ = browserRequest "/private/dashboard/v1/snapshot" ($"""{{"workspaceId":"{owner.Workspace}"}}""") (Some session) + Assert.Equal(HttpStatusCode.OK, displayed) + let projection = JsonNode.Parse bytes + Assert.Equal("fsgg.telemetry.private-dashboard/3", projection["schema"].GetValue()) + let passive = Assert.Single(projection["unassignedUsage"].["observations"].AsArray()) + Assert.Equal("2", passive["revision"].GetValue()) + Assert.Equal("22", passive["total"].GetValue()) + Assert.Null(passive["cachedInput"]) + Assert.Equal(0, projection["items"].AsArray().Count) + let text = Encoding.UTF8.GetString bytes + for privateValue in [nativeThread; nativeTurn; "host-response"; identity; "sourceInstance"; "sourceDigest"] do Assert.DoesNotContain(privateValue, text) + let! outside, outsideBytes, _ = browserRequest "/private/dashboard/v1/snapshot" """{"workspaceId":"outside-workspace"}""" (Some session) + Assert.Equal(HttpStatusCode.NotFound, outside) + Assert.DoesNotContain("22", Encoding.UTF8.GetString outsideBytes) + Assert.True(BrowserComposition.snapshotWithAssessment config (fun _ -> approved) "outside-workspace" None |> Result.isError) use connection = new SqliteConnection($"Data Source={Path.Combine(store, TelemetryStoreApplication.databaseFileName)};Mode=ReadOnly;Pooling=False") connection.Open() let count (sql: string) =