Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions src-tauri/src/usage_diagnostics/sanitize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use super::types::{
};

const UNKNOWN_BUCKET: &str = "unknown";
const MAX_RUNTIME_OPERATIONS: usize = 100;
const MAX_RUNTIME_OPERATIONS: usize = 128;
const MAX_LIST_ENTRIES: usize = 25;

pub fn bucket_duration_ms(duration_ms: f64) -> &'static str {
Expand Down Expand Up @@ -105,10 +105,24 @@ fn sanitize_runtime_summary(summary: DiagnosticsRuntimeSummary) -> DiagnosticsRu
item.insert("total".into(), Value::from(total));
item.insert("success".into(), Value::from(success));
item.insert("failure".into(), Value::from(failure));
item.insert(
"durationBucket".into(),
Value::from(bucket_string_field(&value, "durationBucket")),
);
if value.get("averageDurationBucket").is_some() || value.get("p95DurationBucket").is_some()
{
item.insert(
"averageDurationBucket".into(),
Value::from(bucket_string_field(&value, "averageDurationBucket")),
);
item.insert(
"p95DurationBucket".into(),
Value::from(bucket_string_field(&value, "p95DurationBucket")),
);
} else {
// Queue records produced by diagnostics schema v1 used one coarse
// duration bucket. Keep accepting them while emitting v2 snapshots.
item.insert(
"durationBucket".into(),
Value::from(bucket_string_field(&value, "durationBucket")),
);
}
by_operation.insert(sanitize_operation_name(&operation), Value::Object(item));
}

Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/usage_diagnostics/types.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

pub const DIAGNOSTICS_SCHEMA_VERSION: u32 = 1;
pub const DIAGNOSTICS_SCHEMA_VERSION: u32 = 2;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
Expand Down
29 changes: 27 additions & 2 deletions src-tauri/src/usage_diagnostics/usage_diagnostics_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ fn performance_only_strips_usage_aggregates() {
snapshot(DiagnosticsLevel::Default),
DiagnosticsLevel::PerformanceOnly,
);
assert_eq!(sanitized.schema_version, 1);
assert_eq!(sanitized.schema_version, 2);
assert_eq!(sanitized.app_version.as_deref(), Some("1.0.1+test"));
assert!(sanitized.app_usage_duration_bucket.is_some());
assert!(sanitized.system_profile.is_some());
Expand All @@ -125,7 +125,7 @@ fn performance_only_strips_usage_aggregates() {
#[test]
fn off_keeps_minimal_existence_usage_only() {
let sanitized = sanitize_snapshot(snapshot(DiagnosticsLevel::Default), DiagnosticsLevel::Off);
assert_eq!(sanitized.schema_version, 1);
assert_eq!(sanitized.schema_version, 2);
assert_eq!(sanitized.diagnostics_level, DiagnosticsLevel::Off);
assert_eq!(sanitized.app_version.as_deref(), Some("1.0.1+test"));
assert!(sanitized.app_usage_duration_bucket.is_some());
Expand Down Expand Up @@ -158,6 +158,31 @@ fn default_sanitization_drops_raw_or_path_fields() {
assert!(serialized.contains("agent_send_message"));
}

#[test]
fn runtime_sanitizer_accepts_v1_and_v2_duration_shapes() {
let mut legacy = snapshot(DiagnosticsLevel::PerformanceOnly);
let sanitized_legacy = sanitize_snapshot(legacy.clone(), DiagnosticsLevel::PerformanceOnly);
let legacy_operation = &sanitized_legacy.rpc.unwrap().by_operation["agent_send_message"];
assert_eq!(legacy_operation["durationBucket"], "lt_1m");

legacy.rpc.as_mut().unwrap().by_operation.insert(
"cli_agent_message".to_string(),
json!({
"total": 2,
"success": 1,
"failure": 1,
"averageDurationBucket": "20_100ms",
"p95DurationBucket": "500ms_2s",
"payload": "drop"
}),
);
let sanitized_v2 = sanitize_snapshot(legacy, DiagnosticsLevel::PerformanceOnly);
let v2_operation = &sanitized_v2.rpc.unwrap().by_operation["cli_agent_message"];
assert_eq!(v2_operation["averageDurationBucket"], "20_100ms");
assert_eq!(v2_operation["p95DurationBucket"], "500ms_2s");
assert!(v2_operation.get("payload").is_none());
}

#[test]
fn queue_reads_only_unsent_and_marks_sent() {
let dir = tempfile::tempdir().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion src/diagnostics/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import type {
DiagnosticsUsageSnapshot,
} from "./types";

const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
const MAX_TOP_MODELS = 10;
const MAX_RUST_AGENT_TOP_SESSIONS_PER_DAY = 10;
const EXTERNAL_HISTORY_LIMIT = 200;
Expand Down
68 changes: 68 additions & 0 deletions src/diagnostics/runtimeCounters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it } from "vitest";

import {
consumeHttpDiagnosticsSummary,
consumeRpcDiagnosticsSummary,
discardRuntimeDiagnosticsCounters,
recordDiagnosticsRpc,
} from "./runtimeCounters";

describe("runtime diagnostics counters", () => {
beforeEach(() => {
consumeRpcDiagnosticsSummary();
consumeHttpDiagnosticsSummary();
});

it("reports average, p95, and failures from a fixed histogram", () => {
for (let index = 0; index < 95; index += 1) {
recordDiagnosticsRpc("fast", 4, true);
}
for (let index = 0; index < 5; index += 1) {
recordDiagnosticsRpc("fast", 600, index !== 4);
}

const summary = consumeRpcDiagnosticsSummary();

expect(summary).toMatchObject({ total: 100, success: 99, failure: 1 });
expect(summary.byOperation.fast).toEqual({
total: 100,
success: 99,
failure: 1,
averageDurationBucket: "20_100ms",
p95DurationBucket: "1_5ms",
});
});

it("keeps at most 128 operation entries and merges overflow", () => {
for (let index = 0; index < 1_000; index += 1) {
recordDiagnosticsRpc(`operation-${index}`, index % 10, index % 11 !== 0);
}

const summary = consumeRpcDiagnosticsSummary();
const operations = Object.keys(summary.byOperation);

expect(operations).toHaveLength(128);
expect(summary.byOperation.__other__).toMatchObject({
total: 873,
failure: 79,
});
});

it("consumes and releases the current interval", () => {
recordDiagnosticsRpc("one", 1, false);
expect(consumeRpcDiagnosticsSummary().total).toBe(1);
expect(consumeRpcDiagnosticsSummary()).toEqual({
total: 0,
success: 0,
failure: 0,
byOperation: {},
});
});

it("discards bounded counters while diagnostics cannot upload", () => {
recordDiagnosticsRpc("offline", 2_500, true);
discardRuntimeDiagnosticsCounters();

expect(consumeRpcDiagnosticsSummary().total).toBe(0);
});
});
95 changes: 81 additions & 14 deletions src/diagnostics/runtimeCounters.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,83 @@
import { bucketDurationMs } from "./buckets";
import type { DiagnosticsRuntimeSummary } from "./types";

interface RuntimeCounter {
total: number;
failure: number;
durations: number[];
totalDurationMs: number;
durationHistogram: number[];
}

const MAX_RUNTIME_OPERATIONS = 128;
const OTHER_OPERATION = "__other__";
const DURATION_BUCKETS = [
{ upperBoundMs: 1, label: "lt_1ms" },
{ upperBoundMs: 5, label: "1_5ms" },
{ upperBoundMs: 20, label: "5_20ms" },
{ upperBoundMs: 100, label: "20_100ms" },
{ upperBoundMs: 500, label: "100_500ms" },
{ upperBoundMs: 2_000, label: "500ms_2s" },
{ upperBoundMs: Number.POSITIVE_INFINITY, label: "2s_plus" },
] as const;

const rpcCounters = new Map<string, RuntimeCounter>();
const httpCounters = new Map<string, RuntimeCounter>();

function createCounter(): RuntimeCounter {
return {
total: 0,
failure: 0,
totalDurationMs: 0,
durationHistogram: Array.from({ length: DURATION_BUCKETS.length }, () => 0),
};
}

function getCounter(
counters: Map<string, RuntimeCounter>,
operation: string
): RuntimeCounter {
const existing = counters.get(operation);
if (existing) return existing;
const created: RuntimeCounter = { total: 0, failure: 0, durations: [] };
counters.set(operation, created);

const boundedOperation =
counters.size < MAX_RUNTIME_OPERATIONS - 1 ? operation : OTHER_OPERATION;
const overflow = counters.get(boundedOperation);
if (overflow) return overflow;

const created = createCounter();
counters.set(boundedOperation, created);
return created;
}

function normalizeDuration(durationMs: number): number {
return Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0;
}

function durationBucketIndex(durationMs: number): number {
const index = DURATION_BUCKETS.findIndex(
({ upperBoundMs }) => durationMs < upperBoundMs
);
return index === -1 ? DURATION_BUCKETS.length - 1 : index;
}

function recordCounter(
counter: RuntimeCounter,
durationMs: number,
ok: boolean
): void {
const normalizedDurationMs = normalizeDuration(durationMs);
counter.total += 1;
counter.totalDurationMs += normalizedDurationMs;
counter.durationHistogram[durationBucketIndex(normalizedDurationMs)] += 1;
if (!ok) counter.failure += 1;
}

export function recordDiagnosticsRpc(
command: string,
durationMs: number,
ok: boolean
): void {
const counter = getCounter(rpcCounters, command);
counter.total += 1;
if (!ok) counter.failure += 1;
counter.durations.push(durationMs);
recordCounter(counter, durationMs, ok);
}

export function recordDiagnosticsHttp(
Expand All @@ -38,14 +86,22 @@ export function recordDiagnosticsHttp(
ok: boolean
): void {
const counter = getCounter(httpCounters, target);
counter.total += 1;
if (!ok) counter.failure += 1;
counter.durations.push(durationMs);
recordCounter(counter, durationMs, ok);
}

function bucketLabelForDuration(durationMs: number): string {
return DURATION_BUCKETS[durationBucketIndex(durationMs)].label;
}

function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
function percentileBucket(histogram: number[], total: number): string {
if (total === 0) return DURATION_BUCKETS[0].label;
const target = Math.ceil(total * 0.95);
let cumulative = 0;
for (let index = 0; index < histogram.length; index += 1) {
cumulative += histogram[index] ?? 0;
if (cumulative >= target) return DURATION_BUCKETS[index].label;
}
return DURATION_BUCKETS[DURATION_BUCKETS.length - 1].label;
}

function consumeDiagnosticsSummary(
Expand All @@ -62,7 +118,13 @@ function consumeDiagnosticsSummary(
total: counter.total,
success: counter.total - counter.failure,
failure: counter.failure,
durationBucket: bucketDurationMs(average(counter.durations)),
averageDurationBucket: bucketLabelForDuration(
counter.total === 0 ? 0 : counter.totalDurationMs / counter.total
),
p95DurationBucket: percentileBucket(
counter.durationHistogram,
counter.total
),
};
}

Expand All @@ -77,3 +139,8 @@ export function consumeRpcDiagnosticsSummary(): DiagnosticsRuntimeSummary {
export function consumeHttpDiagnosticsSummary(): DiagnosticsRuntimeSummary {
return consumeDiagnosticsSummary(httpCounters);
}

export function discardRuntimeDiagnosticsCounters(): void {
consumeRpcDiagnosticsSummary();
consumeHttpDiagnosticsSummary();
}
3 changes: 2 additions & 1 deletion src/diagnostics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export interface DiagnosticsRuntimeOperationSummary {
total: number;
success: number;
failure: number;
durationBucket: string;
averageDurationBucket: string;
p95DurationBucket: string;
}

export interface DiagnosticsRuntimeSummary {
Expand Down
8 changes: 8 additions & 0 deletions src/diagnostics/useDiagnosticsBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { workspaceFoldersAtom } from "@src/store/ui/workspaceFoldersAtom";
import type { WorkspaceFolder } from "@src/types/workspace";

import { createDiagnosticsUsageSnapshot } from "./aggregate";
import { discardRuntimeDiagnosticsCounters } from "./runtimeCounters";
import {
diagnosticsInitialize,
diagnosticsSubmitUsageSnapshot,
Expand All @@ -24,6 +25,10 @@ const HOUR_MS = 60 * MINUTE_MS;
const LAST_FLUSH_STORAGE_KEY = "orgii:diagnostics:lastFlushAt";
const logger = createLogger("DiagnosticsBootstrap");

function discardRuntimeDiagnostics(): void {
discardRuntimeDiagnosticsCounters();
}

function reportDiagnosticsFailure(operation: string, error: unknown): void {
logger.warn(`${operation} failed`, error);
}
Expand Down Expand Up @@ -123,6 +128,9 @@ export function useDiagnosticsBootstrap(): void {

useEffect(() => {
if (!settingsLoaded) return;
if (offlineMode) {
discardRuntimeDiagnostics();
}

const generation = ++schedulerGenerationRef.current;
let cancelled = false;
Expand Down
Loading