From 67fb950e61e37ea7ab40ceba4dfd9fde565a83e3 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Thu, 16 Apr 2026 11:10:23 +0200 Subject: [PATCH] feat: add OpenTelemetry settings UI and sidecar environment wiring Add a global Telemetry section in the settings panel with: - Toggle to enable/disable OTLP export - Text input for the OTLP endpoint URL (default: http://localhost:4317) - Guidance note about using \un run aspire\ for local testing Wire the setting through the full stack: - OpenTelemetrySettings type in shared domain with normalization - IPC channel, preload binding, and handler for persistence - SidecarClient forwards settings to createSidecarEnvironment - Sidecar environment injects OTEL_EXPORTER_OTLP_ENDPOINT when enabled - Settings loaded from workspace.json on startup and on change Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/AryxAppService.ts | 9 +++ src/main/ipc/registerIpcHandlers.ts | 6 +- src/main/sidecar/sidecarEnvironment.ts | 11 +++- src/main/sidecar/sidecarProcess.ts | 8 ++- src/preload/index.ts | 1 + src/renderer/App.tsx | 2 + src/renderer/components/SettingsPanel.tsx | 80 ++++++++++++++++++++++- src/shared/contracts/channels.ts | 1 + src/shared/contracts/ipc.ts | 2 + src/shared/domain/tooling.ts | 18 +++++ tests/main/sidecarEnvironment.test.ts | 42 ++++++++++++ tests/shared/tooling.test.ts | 36 ++++++++++ 12 files changed, 210 insertions(+), 6 deletions(-) diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 02875b0..dbbfd51 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -140,6 +140,7 @@ import { type AppearanceTheme, type LspProfileDefinition, type McpServerDefinition, + type OpenTelemetrySettings, type QuickPromptSettings, type SessionToolingSelection, type WorkspaceToolingSettings, @@ -469,6 +470,7 @@ export class AryxAppService extends EventEmitter { async loadWorkspace(): Promise { if (!this.workspace) { this.workspace = await this.workspaceRepository.load(); + this.sidecar.setOpenTelemetrySettings(this.workspace.settings.openTelemetry); const selectedProjectId = this.workspace.selectedProjectId; const selectedProject = selectedProjectId ? this.workspace.projects.find((project) => project.id === selectedProjectId) @@ -830,6 +832,13 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async setOpenTelemetry(settings: OpenTelemetrySettings): Promise { + const workspace = await this.loadWorkspace(); + workspace.settings.openTelemetry = settings; + this.sidecar.setOpenTelemetrySettings(settings); + return this.persistAndBroadcast(workspace); + } + getQuickPromptSettings(): QuickPromptSettings { return this.workspace?.settings.quickPrompt ?? createDefaultQuickPromptSettings(); } diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 0af1567..11a7be5 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -56,7 +56,7 @@ import type { QuickPromptSendInput, } from '@shared/contracts/ipc'; import type { QuerySessionsInput } from '@shared/domain/sessionLibrary'; -import type { AppearanceTheme, QuickPromptSettings } from '@shared/domain/tooling'; +import type { AppearanceTheme, OpenTelemetrySettings, QuickPromptSettings } from '@shared/domain/tooling'; import { AryxAppService } from '@main/AryxAppService'; import { AutoUpdateService } from '@main/services/autoUpdater'; @@ -155,6 +155,10 @@ export function registerIpcHandlers( ipcChannels.setGitAutoRefreshEnabled, (_event, enabled: boolean) => service.setGitAutoRefreshEnabled(enabled), ); + ipcMain.handle( + ipcChannels.setOpenTelemetry, + (_event, settings: OpenTelemetrySettings) => service.setOpenTelemetry(settings), + ); ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates()); ipcMain.handle(ipcChannels.installUpdate, () => { autoUpdateService.installUpdate(); diff --git a/src/main/sidecar/sidecarEnvironment.ts b/src/main/sidecar/sidecarEnvironment.ts index d13b0a7..f8809b5 100644 --- a/src/main/sidecar/sidecarEnvironment.ts +++ b/src/main/sidecar/sidecarEnvironment.ts @@ -1,6 +1,11 @@ +import type { OpenTelemetrySettings } from '@shared/domain/tooling'; + const blockedEnvironmentPrefixes = ['BUN_', 'COPILOT_', 'ELECTRON_', 'NODE_', 'NPM_']; -export function createSidecarEnvironment(baseEnvironment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +export function createSidecarEnvironment( + baseEnvironment: NodeJS.ProcessEnv, + openTelemetry?: OpenTelemetrySettings, +): NodeJS.ProcessEnv { const sanitizedEnvironment: NodeJS.ProcessEnv = {}; for (const [name, value] of Object.entries(baseEnvironment)) { @@ -13,5 +18,9 @@ export function createSidecarEnvironment(baseEnvironment: NodeJS.ProcessEnv): No sanitizedEnvironment[name] = value; } + if (openTelemetry?.enabled && openTelemetry.endpoint) { + sanitizedEnvironment.OTEL_EXPORTER_OTLP_ENDPOINT = openTelemetry.endpoint; + } + return sanitizedEnvironment; } diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index 168cd9e..c44dd94 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -21,6 +21,7 @@ import type { } from '@shared/contracts/sidecar'; import type { ApprovalDecision } from '@shared/domain/approval'; import type { ChatMessageRecord } from '@shared/domain/session'; +import type { OpenTelemetrySettings } from '@shared/domain/tooling'; import { createSidecarEnvironment } from '@main/sidecar/sidecarEnvironment'; import { markRunTurnPendingErrored, @@ -109,6 +110,11 @@ export class SidecarClient { private processState?: ManagedSidecarProcess; private nextProcessId = 0; private readonly pending = new Map(); + private openTelemetrySettings?: OpenTelemetrySettings; + + setOpenTelemetrySettings(settings?: OpenTelemetrySettings): void { + this.openTelemetrySettings = settings; + } async describeCapabilities(): Promise { const command = await this.dispatch({ @@ -240,7 +246,7 @@ export class SidecarClient { }); const childProcess = spawn(sidecar.command, sidecar.args, { cwd: sidecar.cwd, - env: createSidecarEnvironment(process.env), + env: createSidecarEnvironment(process.env, this.openTelemetrySettings), stdio: 'pipe', windowsHide: true, }); diff --git a/src/preload/index.ts b/src/preload/index.ts index 3775a42..3b35249 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -36,6 +36,7 @@ const api: ElectronApi = { setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled), setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled), setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, enabled), + setOpenTelemetry: (settings) => ipcRenderer.invoke(ipcChannels.setOpenTelemetry, settings), getQuickPromptSettings: () => ipcRenderer.invoke(ipcChannels.quickPromptGetSettings), setQuickPromptSettings: (settings) => ipcRenderer.invoke(ipcChannels.quickPromptSetSettings, settings), checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4397d46..b8ad49b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -873,6 +873,8 @@ export default function App() { setQuickPromptSettings(updated); void api.setQuickPromptSettings(patch); }} + openTelemetry={workspace.settings.openTelemetry} + onSetOpenTelemetry={(settings) => void api.setOpenTelemetry(settings)} /> ) : null; diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 6ec3552..4511c75 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,9 +1,9 @@ import { useEffect, useState, type ReactNode } from 'react'; -import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, Sparkles, TriangleAlert, UserCircle, Wrench } from 'lucide-react'; +import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, Sparkles, TriangleAlert, UserCircle, Wrench, Activity } from 'lucide-react'; import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard'; import { WorkflowEditor } from '@renderer/components/WorkflowEditor'; -import { HotkeyRecorder, ToggleSwitch } from '@renderer/components/ui'; +import { HotkeyRecorder, TextInput, ToggleSwitch } from '@renderer/components/ui'; import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor'; import { McpServerEditor } from '@renderer/components/settings/McpServerEditor'; import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor'; @@ -18,9 +18,11 @@ import type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shar import { normalizeLspProfileDefinition, normalizeMcpServerDefinition, + DEFAULT_OTEL_ENDPOINT, type AppearanceTheme, type LspProfileDefinition, type McpServerDefinition, + type OpenTelemetrySettings, type QuickPromptSettings, type WorkspaceToolingSettings, } from '@shared/domain/tooling'; @@ -65,9 +67,11 @@ interface SettingsPanelProps { onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise; quickPromptSettings?: QuickPromptSettings; onSetQuickPromptSettings?: (patch: Partial) => void; + openTelemetry?: OpenTelemetrySettings; + onSetOpenTelemetry?: (settings: OpenTelemetrySettings) => void; } -export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'troubleshooting'; +export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'telemetry' | 'troubleshooting'; interface NavItem { id: SettingsSection; @@ -86,6 +90,7 @@ const navGroups: NavGroup[] = [ items: [ { id: 'appearance', label: 'Appearance', icon: }, { id: 'quick-prompt', label: 'Quick Prompt', icon: }, + { id: 'telemetry', label: 'Telemetry', icon: }, ], }, { @@ -155,6 +160,8 @@ export function SettingsPanel({ onCreateWorkflowFromTemplate, quickPromptSettings, onSetQuickPromptSettings, + openTelemetry, + onSetOpenTelemetry, }: SettingsPanelProps) { const [activeSection, setActiveSection] = useState(initialSection ?? 'appearance'); const [editingWorkflow, setEditingWorkflow] = useState(null); @@ -394,6 +401,12 @@ export function SettingsPanel({ onUpdate={onSetQuickPromptSettings} /> )} + {activeSection === 'telemetry' && ( + + )} {activeSection === 'troubleshooting' && ( void; +}) { + const enabled = openTelemetry?.enabled ?? false; + const endpoint = openTelemetry?.endpoint ?? DEFAULT_OTEL_ENDPOINT; + + const handleToggle = () => { + onSetOpenTelemetry?.({ enabled: !enabled, endpoint }); + }; + + const handleEndpointChange = (value: string) => { + onSetOpenTelemetry?.({ enabled, endpoint: value }); + }; + + return ( +
+
+

OpenTelemetry

+

+ Export traces from the sidecar to an OTLP-compatible collector +

+
+ + + +
+ + +

+ Use bun run aspire to launch the Aspire Dashboard locally at this default endpoint. + Changes take effect on the next sidecar session. +

+
+
+ ); +} + function ConnectionSection({ connection, modelCount, diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 1804a18..5bc29d8 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -25,6 +25,7 @@ export const ipcChannels = { setNotificationsEnabled: 'settings:set-notifications-enabled', setMinimizeToTray: 'settings:set-minimize-to-tray', setGitAutoRefreshEnabled: 'settings:set-git-auto-refresh-enabled', + setOpenTelemetry: 'settings:set-opentelemetry', checkForUpdates: 'app:check-for-updates', installUpdate: 'app:install-update', saveMcpServer: 'tooling:mcp:save', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index a0b111d..59cf9d6 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -20,6 +20,7 @@ import type { SessionToolingSelection, AppearanceTheme, QuickPromptSettings, + OpenTelemetrySettings, } from '@shared/domain/tooling'; import type { WorkspaceState } from '@shared/domain/workspace'; import type { ChatMessageAttachment } from '@shared/domain/attachment'; @@ -364,6 +365,7 @@ export interface ElectronApi { setNotificationsEnabled(enabled: boolean): Promise; setMinimizeToTray(enabled: boolean): Promise; setGitAutoRefreshEnabled(enabled: boolean): Promise; + setOpenTelemetry(settings: OpenTelemetrySettings): Promise; checkForUpdates(): Promise; installUpdate(): Promise; describeTerminal(): Promise; diff --git a/src/shared/domain/tooling.ts b/src/shared/domain/tooling.ts index d1c4e25..421794d 100644 --- a/src/shared/domain/tooling.ts +++ b/src/shared/domain/tooling.ts @@ -68,6 +68,13 @@ export interface QuickPromptSettings { defaultReasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; } +export interface OpenTelemetrySettings { + enabled: boolean; + endpoint: string; +} + +export const DEFAULT_OTEL_ENDPOINT = 'http://localhost:4317'; + export function createDefaultQuickPromptSettings(): QuickPromptSettings { return { enabled: true, @@ -85,6 +92,7 @@ export interface WorkspaceSettings { minimizeToTray?: boolean; gitAutoRefreshEnabled?: boolean; quickPrompt?: QuickPromptSettings; + openTelemetry?: OpenTelemetrySettings; } export interface SessionToolingSelection { @@ -231,6 +239,16 @@ export function normalizeWorkspaceSettings(settings?: Partial ...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}), ...(settings?.gitAutoRefreshEnabled !== undefined ? { gitAutoRefreshEnabled: settings.gitAutoRefreshEnabled } : {}), ...(settings?.quickPrompt !== undefined ? { quickPrompt: settings.quickPrompt } : {}), + ...(settings?.openTelemetry !== undefined ? { openTelemetry: normalizeOpenTelemetrySettings(settings.openTelemetry) } : {}), + }; +} + +export function normalizeOpenTelemetrySettings(settings?: Partial): OpenTelemetrySettings { + return { + enabled: settings?.enabled === true, + endpoint: typeof settings?.endpoint === 'string' && settings.endpoint.trim() !== '' + ? settings.endpoint.trim() + : DEFAULT_OTEL_ENDPOINT, }; } diff --git a/tests/main/sidecarEnvironment.test.ts b/tests/main/sidecarEnvironment.test.ts index 33d60a1..c9b52ac 100644 --- a/tests/main/sidecarEnvironment.test.ts +++ b/tests/main/sidecarEnvironment.test.ts @@ -37,4 +37,46 @@ describe('createSidecarEnvironment', () => { HTTPS_PROXY: 'http://proxy.local:8080', }); }); + + test('injects OTEL_EXPORTER_OTLP_ENDPOINT when OpenTelemetry is enabled', () => { + expect( + createSidecarEnvironment( + { PATH: 'C:\\tools' }, + { enabled: true, endpoint: 'http://localhost:4317' }, + ), + ).toEqual({ + PATH: 'C:\\tools', + OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4317', + }); + }); + + test('does not inject OTEL_EXPORTER_OTLP_ENDPOINT when OpenTelemetry is disabled', () => { + expect( + createSidecarEnvironment( + { PATH: 'C:\\tools' }, + { enabled: false, endpoint: 'http://localhost:4317' }, + ), + ).toEqual({ + PATH: 'C:\\tools', + }); + }); + + test('does not inject OTEL_EXPORTER_OTLP_ENDPOINT when settings are undefined', () => { + expect( + createSidecarEnvironment({ PATH: 'C:\\tools' }), + ).toEqual({ + PATH: 'C:\\tools', + }); + }); + + test('does not inject OTEL_EXPORTER_OTLP_ENDPOINT when endpoint is empty', () => { + expect( + createSidecarEnvironment( + { PATH: 'C:\\tools' }, + { enabled: true, endpoint: '' }, + ), + ).toEqual({ + PATH: 'C:\\tools', + }); + }); }); diff --git a/tests/shared/tooling.test.ts b/tests/shared/tooling.test.ts index 3cf85d8..42891f6 100644 --- a/tests/shared/tooling.test.ts +++ b/tests/shared/tooling.test.ts @@ -6,12 +6,14 @@ import { groupApprovalToolsByProvider, listApprovalToolDefinitions, listApprovalToolNames, + normalizeOpenTelemetrySettings, normalizeWorkspaceSettings, resolveProjectToolingSettings, resolveToolLabel, resolveWorkspaceToolingSettings, validateLspProfileDefinition, validateMcpServerDefinition, + DEFAULT_OTEL_ENDPOINT, type LspProfileDefinition, type McpServerDefinition, type WorkspaceToolingSettings, @@ -118,6 +120,40 @@ describe('tooling settings helpers', () => { expect(normalizeWorkspaceSettings({ terminalHeight: Number.NaN }).terminalHeight).toBeUndefined(); }); + test('normalizes OpenTelemetry settings with defaults', () => { + const otel = normalizeOpenTelemetrySettings(); + expect(otel.enabled).toBe(false); + expect(otel.endpoint).toBe(DEFAULT_OTEL_ENDPOINT); + }); + + test('preserves valid OpenTelemetry settings', () => { + const otel = normalizeOpenTelemetrySettings({ enabled: true, endpoint: 'http://custom:4317' }); + expect(otel.enabled).toBe(true); + expect(otel.endpoint).toBe('http://custom:4317'); + }); + + test('trims whitespace from OpenTelemetry endpoint', () => { + const otel = normalizeOpenTelemetrySettings({ enabled: true, endpoint: ' http://localhost:4317 ' }); + expect(otel.endpoint).toBe('http://localhost:4317'); + }); + + test('falls back to default endpoint when blank', () => { + const otel = normalizeOpenTelemetrySettings({ enabled: true, endpoint: ' ' }); + expect(otel.endpoint).toBe(DEFAULT_OTEL_ENDPOINT); + }); + + test('normalizeWorkspaceSettings preserves OpenTelemetry when present', () => { + const settings = normalizeWorkspaceSettings({ + openTelemetry: { enabled: true, endpoint: 'http://jaeger:4317' }, + }); + expect(settings.openTelemetry).toEqual({ enabled: true, endpoint: 'http://jaeger:4317' }); + }); + + test('normalizeWorkspaceSettings omits OpenTelemetry when absent', () => { + const settings = normalizeWorkspaceSettings({}); + expect(settings.openTelemetry).toBeUndefined(); + }); + test('validates required MCP transport settings', () => { const localServer: McpServerDefinition = { id: 'mcp-local',