Files
galaxy/app/src/ai/blocklist/telemetry.rs
T

360 lines
13 KiB
Rust

use galaxy_core::features::FeatureFlag;
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use serde::Serialize;
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use crate::ai::agent::conversation::AIConversationId;
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub(crate) enum BlocklistOrchestrationTelemetryEvent {
TeamAgentCommunicationFailed(TeamAgentCommunicationFailedEvent),
PlanConfigApprovalToggled(PlanConfigApprovalToggledEvent),
RunAgentsCardDecision(RunAgentsCardDecisionEvent),
PillBarInteraction(PillBarInteractionEvent),
OrchestrationEntered(OrchestrationEnteredEvent),
AgentProposedConfig(AgentProposedConfigEvent),
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub(crate) enum TeamAgentCommunicationKind {
Message,
LifecycleEvent,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub(crate) enum TeamAgentCommunicationTransport {
Local,
ServerApi,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub(crate) enum TeamAgentOrchestrationVersion {
V1,
V2,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub(crate) enum TeamAgentCommunicationFailureReason {
InvalidLifecycleEventType,
MissingSourceConversation,
MissingSourceIdentifier,
UnknownAgent,
NoTargets,
RequestFailed,
}
#[derive(Debug, Serialize)]
pub(crate) struct TeamAgentCommunicationFailedEvent {
pub communication_kind: TeamAgentCommunicationKind,
pub transport: TeamAgentCommunicationTransport,
pub orchestration_version: TeamAgentOrchestrationVersion,
pub failure_reason: TeamAgentCommunicationFailureReason,
pub source_conversation_id: AIConversationId,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lifecycle_event_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
}
/// Coarse approval transition for the plan card's
/// `Use orchestration` toggle.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum OrchestrationApprovalStatus {
Approved,
Disapproved,
}
/// Run-wide execution mode reported on telemetry payloads. A flat
/// enum so the payload stays metadata-only and never carries an
/// environment id or worker host.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum OrchestrationExecutionModeKind {
Local,
Remote,
}
impl OrchestrationExecutionModeKind {
pub(crate) fn from_run_agents(mode: &ai::agent::action::RunAgentsExecutionMode) -> Self {
if mode.is_remote() {
Self::Remote
} else {
Self::Local
}
}
}
/// Closed-set bucket for the run-wide harness selection. Anything
/// unrecognized collapses to `Unknown` to keep the analytics column
/// low-cardinality.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum OrchestrationHarnessKind {
Oz,
ClaudeCode,
Codex,
OpenCode,
Gemini,
Unknown,
}
impl OrchestrationHarnessKind {
pub(crate) fn from_str(harness_type: &str) -> Self {
match harness_type {
"oz" | "" => Self::Oz,
"claude" | "claude-code" | "claude_code" => Self::ClaudeCode,
"codex" => Self::Codex,
"opencode" | "open-code" | "open_code" => Self::OpenCode,
"gemini" => Self::Gemini,
_ => Self::Unknown,
}
}
}
/// Stable names for run-wide config fields that can diverge between
/// the dispatched orchestration request and either the original tool
/// call or an active approved config. Match the server's equivalent
/// field-name constants so the two telemetry streams can be joined.
pub(crate) mod orchestration_modified_field {
pub const MODEL_ID: &str = "model_id";
pub const HARNESS: &str = "harness";
pub const EXECUTION_MODE: &str = "execution_mode";
pub const ENVIRONMENT_ID: &str = "environment_id";
pub const WORKER_HOST: &str = "worker_host";
pub const AUTH_SECRET: &str = "auth_secret";
}
#[derive(Debug, Serialize)]
pub(crate) struct PlanConfigApprovalToggledEvent {
pub conversation_id: AIConversationId,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_id: Option<String>,
/// State after the toggle. The pre-toggle state is the opposite
/// since this event only fires on a binary flip.
pub status: OrchestrationApprovalStatus,
pub execution_mode: OrchestrationExecutionModeKind,
pub harness: OrchestrationHarnessKind,
pub has_model: bool,
pub has_environment: bool,
pub has_worker_host: bool,
pub has_auth_secret: bool,
}
/// Decision a user took on the run_agents confirmation card.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RunAgentsCardDecision {
Accept,
AcceptWithoutOrchestration,
Reject,
}
#[derive(Debug, Serialize)]
pub(crate) struct RunAgentsCardDecisionEvent {
pub conversation_id: AIConversationId,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_id: Option<String>,
pub decision: RunAgentsCardDecision,
pub agent_count: usize,
pub harness: OrchestrationHarnessKind,
pub execution_mode: OrchestrationExecutionModeKind,
/// Field names from [`orchestration_modified_field`] that diverged
/// between the dispatched request and the LLM's original
/// `RunAgentsRequest`. Empty when the user accepted without edits.
pub modified_fields_from_tool_call: Vec<&'static str>,
/// Same shape, but compared against the approved orchestration
/// config snapshot. Empty when no approved config exists or the
/// dispatched request matches it.
pub modified_fields_from_active_config: Vec<&'static str>,
pub had_active_config: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_config_status: Option<OrchestrationApprovalStatus>,
}
/// Surface that first introduced orchestration into a conversation.
///
/// Plan-card surfacing is intentionally NOT a variant here — that signal
/// is covered by [`AgentProposedConfigEvent`] (fires once per plan card
/// instance when an agent-authored snapshot first becomes visible) plus
/// [`PlanConfigApprovalToggledEvent`] (the user's approval toggle).
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum OrchestrationEntrySource {
/// `/orchestrate` slash-command mode on a user query.
SlashCommandOrchestrate,
/// `run_agents` confirmation card was shown (not auto-launched).
RunAgentsCardShown,
}
#[derive(Debug, Serialize)]
pub(crate) struct OrchestrationEnteredEvent {
pub conversation_id: AIConversationId,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_id: Option<String>,
pub entry_source: OrchestrationEntrySource,
}
/// Fires when an agent-authored orchestration config snapshot first
/// becomes visible to the user on a plan card. One emission per
/// `OrchestrationConfigBlockView` instance.
#[derive(Debug, Serialize)]
pub(crate) struct AgentProposedConfigEvent {
pub conversation_id: AIConversationId,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_id: Option<String>,
pub harness: OrchestrationHarnessKind,
pub execution_mode: OrchestrationExecutionModeKind,
pub has_model: bool,
pub has_environment: bool,
pub has_worker_host: bool,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PillBarPillKind {
Orchestrator,
Child,
}
/// Concrete user actions against an orchestration pill bar entry.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PillBarActionKind {
/// User clicked the pill body. See `switch_outcome` for what
/// happened next.
Switch,
OpenInNewPane,
OpenInNewTab,
/// User picked "Focus pane" from a pill's 3-dot menu. Distinct
/// from a pill-body click that resolves to the same outcome
/// (those are `Switch` with `switch_outcome = focused_existing_pane`).
FocusOpenedConversation,
Stop,
Kill,
TogglePinOn,
TogglePinOff,
ViewInOz,
OpenMenu,
}
/// Outcome of a pill-body click. Closed enum so future navigation
/// outcomes can be added without splitting `Switch` into multiple
/// action variants again.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PillSwitchOutcome {
/// Pill click navigated within the current pane.
SwitchedInPlace,
/// Target conversation was already owned by another visible
/// terminal view; focus moved there instead of switching in place.
FocusedExistingPane,
}
#[derive(Debug, Serialize)]
pub(crate) struct PillBarInteractionEvent {
pub action: PillBarActionKind,
pub pill_kind: PillBarPillKind,
pub total_pills: usize,
pub total_pinned: usize,
/// The orchestrator that hosts the pill bar.
pub source_conversation_id: AIConversationId,
/// The pill the action targets.
pub target_conversation_id: AIConversationId,
/// Present only when `action == Switch`. Distinguishes whether the
/// pill-body click navigated within the current pane or moved
/// focus to an existing pane already owning the conversation.
#[serde(skip_serializing_if = "Option::is_none")]
pub switch_outcome: Option<PillSwitchOutcome>,
}
impl TelemetryEvent for BlocklistOrchestrationTelemetryEvent {
fn name(&self) -> &'static str {
BlocklistOrchestrationTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<Value> {
match self {
Self::TeamAgentCommunicationFailed(event) => Some(json!(event)),
Self::PlanConfigApprovalToggled(event) => Some(json!(event)),
Self::RunAgentsCardDecision(event) => Some(json!(event)),
Self::PillBarInteraction(event) => Some(json!(event)),
Self::OrchestrationEntered(event) => Some(json!(event)),
Self::AgentProposedConfig(event) => Some(json!(event)),
}
}
fn description(&self) -> &'static str {
BlocklistOrchestrationTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
BlocklistOrchestrationTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
false
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
galaxy_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for BlocklistOrchestrationTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
Self::TeamAgentCommunicationFailed => {
"AgentMode.Orchestration.TeamAgentCommunicationFailed"
}
Self::PlanConfigApprovalToggled => "AgentMode.Orchestration.PlanConfigApprovalToggled",
Self::RunAgentsCardDecision => "AgentMode.Orchestration.RunAgentsCardDecision",
Self::PillBarInteraction => "AgentMode.Orchestration.PillBarInteraction",
Self::OrchestrationEntered => "AgentMode.Orchestration.Entered",
Self::AgentProposedConfig => "AgentMode.Orchestration.AgentProposedConfig",
}
}
fn description(&self) -> &'static str {
match self {
Self::TeamAgentCommunicationFailed => {
"Failed to send an orchestration message or lifecycle event for a TeamAgent"
}
Self::PlanConfigApprovalToggled => {
"User toggled the Use orchestration switch on a plan card"
}
Self::RunAgentsCardDecision => {
"User accepted, accepted-without-orchestration, or rejected a run_agents confirmation card. Reports which config fields diverged from the original tool call and/or the active approved config."
}
Self::PillBarInteraction => {
"User interacted with the orchestration pill bar (switch, pin, open in pane/tab, stop, kill, etc.)"
}
Self::OrchestrationEntered => {
"Orchestration was activated in a conversation via /orchestrate or a run_agents confirmation card surfacing. Plan-card entries are tracked separately via AgentProposedConfig + PlanConfigApprovalToggled."
}
Self::AgentProposedConfig => {
"An agent-authored orchestration config snapshot first became visible to the user on a plan card"
}
}
}
fn enablement_state(&self) -> EnablementState {
EnablementState::Always
}
}
galaxy_core::register_telemetry_event!(BlocklistOrchestrationTelemetryEvent);