diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b4453c2..10ad78a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -37,6 +37,7 @@ flowchart LR Git[Local git repositories] Sidecar[.NET sidecar] Copilot[GitHub Copilot CLI+ agent runtime] + Telemetry[OTLP collectorAspire Dashboard] OS[Native windowingand desktop integration] User --> Renderer @@ -46,6 +47,7 @@ flowchart LR Main <--> Git Main <--> Sidecar Sidecar <--> Copilot + Sidecar --> Telemetry Main <--> OS ``` @@ -56,8 +58,8 @@ flowchart LR | Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events | | Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` | | Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, global hotkey registration, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes | -| Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, streaming deltas and activity, streamed text assembly | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio | -| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar | +| Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, optional OTLP trace export, streaming deltas and activity, streamed text assembly | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio, OTLP to external collectors | +| External systems | Git data, Copilot account/model access, telemetry collectors, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar | This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added. @@ -68,7 +70,7 @@ Aryx runs as a multi-process desktop application: 1. The **renderer** displays the workspace and captures user intent. 2. The **preload bridge** exposes a small, typed API into the browser context. 3. The **main process** validates and mutates application state, persists it, and manages native integrations. -4. The **sidecar** executes Copilot-backed turns and streams structured execution events back. +4. The **sidecar** executes Copilot-backed turns, can export OpenTelemetry traces to an external collector, and streams structured execution events back. The sidecar is intentionally separate from the Electron main process so that AI runtime concerns stay isolated from UI and persistence concerns. diff --git a/README.md b/README.md index af0f088..622006b 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ bun run test # typecheck + unit tests bun run sidecar:test # backend tests bun run build # full build (electron + sidecar) +bun run aspire # start the standalone Aspire Dashboard in Docker +bun run dev:otel # start Aspire + Aryx dev with OTLP export enabled bun run package # package for current platform → release/ bun run installer # create installable artifact bun run publish-release # publish to GitHub Releases @@ -99,6 +101,10 @@ bun run release # bump patch version, commit, tag, and push bun run release minor # bump minor version instead (use major for breaking releases) ``` +For local observability, `bun run dev:otel` starts the Aspire Dashboard UI at `http://localhost:18888` +and configures the sidecar to export OpenTelemetry traces to `http://localhost:4317` over OTLP/gRPC. +This dev workflow requires Docker. + Tagged releases use GitHub Actions to build and publish Windows (NSIS), macOS (DMG, signed + notarized), and Linux (AppImage) artifacts. The app uses `electron-updater` for in-app updates. The release helper expects a clean git worktree, an upstream branch configured for the current branch, and permission to push commits and tags. diff --git a/package.json b/package.json index 78fb520..5b34ff2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "main": "dist-electron/main/index.js", "scripts": { "dev": "electron-vite dev", + "dev:otel": "bun run scripts/dev-with-otel.ts", + "aspire": "bun run scripts/launch-aspire-dashboard.ts", "build:electron": "electron-vite build", "build": "bun run build:electron && bun run sidecar:build", "package": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --dir --publish never", diff --git a/scripts/aspireDashboard.ts b/scripts/aspireDashboard.ts new file mode 100644 index 0000000..81de017 --- /dev/null +++ b/scripts/aspireDashboard.ts @@ -0,0 +1,233 @@ +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import { setTimeout as delay } from 'node:timers/promises'; + +export const aspireDashboardContainerName = 'aryx-aspire-dashboard'; +export const aspireDashboardImage = 'mcr.microsoft.com/dotnet/aspire-dashboard:latest'; +export const aspireDashboardUrls = { + dashboard: 'http://localhost:18888', + otlpGrpc: 'http://localhost:4317', + otlpHttp: 'http://localhost:4318', +} as const; + +type ContainerState = 'missing' | 'running' | 'stopped'; + +export interface AspireDashboardHandle { + readonly dashboardUrl: string; + readonly otlpGrpcEndpoint: string; + readonly otlpHttpEndpoint: string; + readonly startedByScript: boolean; +} + +export function createAspireDashboardRunArgs( + containerName = aspireDashboardContainerName, + image = aspireDashboardImage, +): string[] { + return [ + 'run', + '--rm', + '-d', + '--name', + containerName, + '-p', + '18888:18888', + '-p', + '4317:18889', + '-p', + '4318:18890', + '-e', + 'ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true', + image, + ]; +} + +export function createOpenTelemetryEnvironment( + baseEnvironment: NodeJS.ProcessEnv, + endpoint: string = aspireDashboardUrls.otlpGrpc, +): NodeJS.ProcessEnv { + return { + ...baseEnvironment, + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, + OTEL_EXPORTER_OTLP_PROTOCOL: 'grpc', + }; +} + +export async function startAspireDashboard(): Promise { + await ensureDockerAvailable(); + + let startedByScript = false; + const containerState = await getContainerState(aspireDashboardContainerName); + if (containerState !== 'running') { + if (containerState === 'stopped') { + await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe'); + } + + await runCommand('docker', createAspireDashboardRunArgs(), 'pipe'); + startedByScript = true; + } + + await waitForTcpPort(18888); + await waitForTcpPort(4317); + + return { + dashboardUrl: aspireDashboardUrls.dashboard, + otlpGrpcEndpoint: aspireDashboardUrls.otlpGrpc, + otlpHttpEndpoint: aspireDashboardUrls.otlpHttp, + startedByScript, + }; +} + +export async function stopAspireDashboard(): Promise { + const containerState = await getContainerState(aspireDashboardContainerName); + if (containerState === 'missing') { + return; + } + + await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe'); +} + +async function ensureDockerAvailable(): Promise { + const cliVersion = await runCommandCapture('docker', ['--version']); + if (cliVersion.exitCode !== 0) { + throw new Error( + 'Docker is required to run the standalone Aspire Dashboard. Install Docker Desktop and ensure `docker` is available on PATH.', + ); + } + + if (await isDockerDaemonAvailable()) { + return; + } + + if (await hasDockerDesktopCli()) { + console.log('Docker Desktop is not running. Starting it now...'); + await runCommand('docker', ['desktop', 'start', '--detach'], 'pipe'); + await waitForDockerDaemon(); + return; + } + + throw new Error( + 'Docker is installed but the daemon is not running. Start Docker Desktop and rerun the Aspire command.', + ); +} + +async function getContainerState(containerName: string): Promise { + const result = await runCommandCapture('docker', [ + 'container', + 'inspect', + '--format', + '{{.State.Status}}', + containerName, + ]); + + if (result.exitCode !== 0) { + return 'missing'; + } + + return result.stdout.trim() === 'running' ? 'running' : 'stopped'; +} + +async function waitForTcpPort(port: number, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await canConnect(port)) { + return; + } + + await delay(250); + } + + throw new Error(`Timed out waiting for localhost:${port} to accept connections.`); +} + +async function isDockerDaemonAvailable(): Promise { + const result = await runCommandCapture('docker', ['info', '--format', '{{.ServerVersion}}']); + return result.exitCode === 0; +} + +async function hasDockerDesktopCli(): Promise { + const result = await runCommandCapture('docker', ['desktop', 'version']); + return result.exitCode === 0; +} + +async function waitForDockerDaemon(timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isDockerDaemonAvailable()) { + return; + } + + await delay(1_000); + } + + throw new Error('Timed out waiting for Docker Desktop to start and accept API connections.'); +} + +async function canConnect(port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + const finalize = (connected: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(connected); + }; + + socket.once('connect', () => finalize(true)); + socket.once('error', () => finalize(false)); + }); +} + +async function runCommand(command: string, args: string[], stdio: 'inherit' | 'pipe'): Promise { + const result = await spawnProcess(command, args, stdio); + if (result.exitCode === 0) { + return; + } + + if (result.stderr.trim().length > 0) { + throw new Error(result.stderr.trim()); + } + + throw new Error(`${command} exited with code ${result.exitCode}.`); +} + +async function runCommandCapture(command: string, args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return spawnProcess(command, args, 'pipe'); +} + +async function spawnProcess( + command: string, + args: string[], + stdio: 'inherit' | 'pipe', +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + let stdout = ''; + let stderr = ''; + + if (stdio === 'pipe') { + child.stdout?.on('data', (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + } + + child.on('error', reject); + child.on('exit', (code, signal) => { + if (signal) { + reject(new Error(`${command} exited because of signal ${signal}.`)); + return; + } + + resolve({ + exitCode: code ?? 1, + stdout, + stderr, + }); + }); + }); +} diff --git a/scripts/dev-with-otel.ts b/scripts/dev-with-otel.ts new file mode 100644 index 0000000..dd8a699 --- /dev/null +++ b/scripts/dev-with-otel.ts @@ -0,0 +1,57 @@ +import { spawn } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + createOpenTelemetryEnvironment, + startAspireDashboard, + stopAspireDashboard, +} from './aspireDashboard'; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDirectory, '..'); +const signalExitCodes: Partial> = { + SIGINT: 130, + SIGTERM: 143, +}; + +const dashboard = await startAspireDashboard(); + +console.log(`Aspire Dashboard: ${dashboard.dashboardUrl}`); +console.log(`Aryx sidecar OTLP endpoint: ${dashboard.otlpGrpcEndpoint}`); + +const child = spawn(process.execPath, ['run', 'dev'], { + cwd: repositoryRoot, + env: createOpenTelemetryEnvironment(process.env, dashboard.otlpGrpcEndpoint), + stdio: 'inherit', + windowsHide: true, +}); + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + if (child.exitCode === null && child.signalCode === null) { + child.kill(signal); + } + }); +} + +let exitResult: { code: number | null; signal: NodeJS.Signals | null }; + +try { + exitResult = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('exit', (code, signal) => { + resolve({ code, signal }); + }); + }); +} finally { + if (dashboard.startedByScript) { + await stopAspireDashboard(); + } +} + +if (exitResult.signal) { + process.exitCode = signalExitCodes[exitResult.signal] ?? 1; +} else { + process.exitCode = exitResult.code ?? 1; +} diff --git a/scripts/launch-aspire-dashboard.ts b/scripts/launch-aspire-dashboard.ts new file mode 100644 index 0000000..ee47878 --- /dev/null +++ b/scripts/launch-aspire-dashboard.ts @@ -0,0 +1,9 @@ +import { startAspireDashboard } from './aspireDashboard'; + +const dashboard = await startAspireDashboard(); + +console.log( + `${dashboard.startedByScript ? 'Started' : 'Reusing'} Aspire Dashboard at ${dashboard.dashboardUrl}`, +); +console.log(`OTLP/gRPC receiver: ${dashboard.otlpGrpcEndpoint}`); +console.log(`OTLP/HTTP receiver: ${dashboard.otlpHttpEndpoint}`); diff --git a/sidecar/src/Aryx.AgentHost/Aryx.AgentHost.csproj b/sidecar/src/Aryx.AgentHost/Aryx.AgentHost.csproj index 13c27a4..a7f54f5 100644 --- a/sidecar/src/Aryx.AgentHost/Aryx.AgentHost.csproj +++ b/sidecar/src/Aryx.AgentHost/Aryx.AgentHost.csproj @@ -13,6 +13,8 @@ + + diff --git a/sidecar/src/Aryx.AgentHost/Program.cs b/sidecar/src/Aryx.AgentHost/Program.cs index f5dce78..4e25cfc 100644 --- a/sidecar/src/Aryx.AgentHost/Program.cs +++ b/sidecar/src/Aryx.AgentHost/Program.cs @@ -1,4 +1,5 @@ using Aryx.AgentHost.Services; +using OpenTelemetry.Trace; if (!args.Contains("--stdio", StringComparer.Ordinal)) { @@ -6,5 +7,7 @@ if (!args.Contains("--stdio", StringComparer.Ordinal)) return; } +using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProviderFromEnvironment(); + SidecarProtocolHost host = new(); await host.RunAsync(Console.In, Console.Out, CancellationToken.None); diff --git a/sidecar/src/Aryx.AgentHost/Services/OpenTelemetrySetup.cs b/sidecar/src/Aryx.AgentHost/Services/OpenTelemetrySetup.cs new file mode 100644 index 0000000..bd781ac --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/OpenTelemetrySetup.cs @@ -0,0 +1,137 @@ +using System.Collections; +using OpenTelemetry; +using OpenTelemetry.Exporter; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Aryx.AgentHost.Services; + +internal static class OpenTelemetrySetup +{ + private const string ServiceName = "Aryx.AgentHost"; + private const string EndpointEnvironmentVariableName = "OTEL_EXPORTER_OTLP_ENDPOINT"; + private const string ProtocolEnvironmentVariableName = "OTEL_EXPORTER_OTLP_PROTOCOL"; + + private static readonly string[] ActivitySourceNames = + [ + "Microsoft.Agents.AI", + "Microsoft.Agents.AI.Workflows", + ]; + + public static TracerProvider? CreateTracerProviderFromEnvironment(TextWriter? diagnosticsWriter = null) + { + return CreateTracerProvider(ReadEnvironmentVariables(), diagnosticsWriter ?? Console.Error); + } + + internal static TracerProvider? CreateTracerProvider( + IEnumerable> environmentVariables, + TextWriter diagnosticsWriter) + { + ArgumentNullException.ThrowIfNull(environmentVariables); + ArgumentNullException.ThrowIfNull(diagnosticsWriter); + + OpenTelemetryTracingConfiguration? configuration = ResolveTracingConfiguration(environmentVariables); + if (configuration is null) + { + return null; + } + + diagnosticsWriter.WriteLine( + $"Aryx.AgentHost OpenTelemetry tracing enabled ({configuration.ProtocolLabel}) -> {configuration.Endpoint}."); + + return Sdk.CreateTracerProviderBuilder() + .SetResourceBuilder( + ResourceBuilder.CreateDefault() + .AddService(ServiceName, serviceVersion: ResolveServiceVersion())) + .AddSource(configuration.ActivitySourceNames.ToArray()) + .AddOtlpExporter(options => + { + options.Endpoint = configuration.Endpoint; + options.Protocol = configuration.Protocol; + }) + .Build(); + } + + internal static OpenTelemetryTracingConfiguration? ResolveTracingConfiguration( + IEnumerable> environmentVariables) + { + ArgumentNullException.ThrowIfNull(environmentVariables); + + string? endpointValue = ReadSetting(environmentVariables, EndpointEnvironmentVariableName); + if (string.IsNullOrWhiteSpace(endpointValue)) + { + return null; + } + + if (!Uri.TryCreate(endpointValue, UriKind.Absolute, out Uri? endpoint) + || (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps)) + { + throw new InvalidOperationException( + $"{EndpointEnvironmentVariableName} must be an absolute http or https URL. Received '{endpointValue}'."); + } + + OtlpExportProtocol protocol = ResolveProtocol(ReadSetting(environmentVariables, ProtocolEnvironmentVariableName)); + return new OpenTelemetryTracingConfiguration(endpoint, protocol, ActivitySourceNames); + } + + private static string? ResolveServiceVersion() + { + return typeof(OpenTelemetrySetup).Assembly.GetName().Version?.ToString(); + } + + private static IEnumerable> ReadEnvironmentVariables() + { + return Environment.GetEnvironmentVariables() + .Cast() + .Select(entry => new KeyValuePair( + entry.Key?.ToString() ?? string.Empty, + entry.Value?.ToString())); + } + + private static string? ReadSetting( + IEnumerable> environmentVariables, + string name) + { + foreach (KeyValuePair entry in environmentVariables) + { + if (!string.Equals(entry.Key, name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return string.IsNullOrWhiteSpace(entry.Value) + ? null + : entry.Value.Trim(); + } + + return null; + } + + private static OtlpExportProtocol ResolveProtocol(string? protocolValue) + { + if (string.IsNullOrWhiteSpace(protocolValue)) + { + return OtlpExportProtocol.Grpc; + } + + return protocolValue.Trim().ToLowerInvariant() switch + { + "grpc" => OtlpExportProtocol.Grpc, + "http/protobuf" => OtlpExportProtocol.HttpProtobuf, + _ => throw new InvalidOperationException( + $"{ProtocolEnvironmentVariableName} must be 'grpc' or 'http/protobuf'. Received '{protocolValue}'."), + }; + } +} + +internal sealed record OpenTelemetryTracingConfiguration( + Uri Endpoint, + OtlpExportProtocol Protocol, + IReadOnlyList ActivitySourceNames) +{ + public string ProtocolLabel => Protocol switch + { + OtlpExportProtocol.HttpProtobuf => "http/protobuf", + _ => "grpc", + }; +} diff --git a/sidecar/tests/Aryx.AgentHost.Tests/OpenTelemetrySetupTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/OpenTelemetrySetupTests.cs new file mode 100644 index 0000000..78db225 --- /dev/null +++ b/sidecar/tests/Aryx.AgentHost.Tests/OpenTelemetrySetupTests.cs @@ -0,0 +1,87 @@ +using Aryx.AgentHost.Services; +using OpenTelemetry.Exporter; +using OpenTelemetry.Trace; + +namespace Aryx.AgentHost.Tests; + +public sealed class OpenTelemetrySetupTests +{ + [Fact] + public void ResolveTracingConfiguration_ReturnsNullWhenEndpointMissing() + { + OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration([]); + + Assert.Null(configuration); + } + + [Fact] + public void ResolveTracingConfiguration_UsesGrpcByDefault() + { + OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration( + [ + new KeyValuePair("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"), + ]); + + Assert.NotNull(configuration); + Assert.Equal(new Uri("http://localhost:4317"), configuration.Endpoint); + Assert.Equal(OtlpExportProtocol.Grpc, configuration.Protocol); + Assert.Collection( + configuration.ActivitySourceNames, + source => Assert.Equal("Microsoft.Agents.AI", source), + source => Assert.Equal("Microsoft.Agents.AI.Workflows", source)); + } + + [Fact] + public void ResolveTracingConfiguration_UsesHttpProtobufWhenRequested() + { + OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration( + [ + new KeyValuePair("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), + new KeyValuePair("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"), + ]); + + Assert.NotNull(configuration); + Assert.Equal(new Uri("http://localhost:4318"), configuration.Endpoint); + Assert.Equal(OtlpExportProtocol.HttpProtobuf, configuration.Protocol); + } + + [Fact] + public void ResolveTracingConfiguration_ThrowsWhenEndpointInvalid() + { + InvalidOperationException error = Assert.Throws(() => + OpenTelemetrySetup.ResolveTracingConfiguration( + [ + new KeyValuePair("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317"), + ])); + + Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveTracingConfiguration_ThrowsWhenProtocolUnsupported() + { + InvalidOperationException error = Assert.Throws(() => + OpenTelemetrySetup.ResolveTracingConfiguration( + [ + new KeyValuePair("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"), + new KeyValuePair("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json"), + ])); + + Assert.Contains("OTEL_EXPORTER_OTLP_PROTOCOL", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void CreateTracerProvider_CreatesProviderAndWritesDiagnosticsWhenConfigured() + { + StringWriter diagnosticsWriter = new(); + + using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProvider( + [ + new KeyValuePair("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"), + ], + diagnosticsWriter); + + Assert.NotNull(tracerProvider); + Assert.Contains("Aryx.AgentHost OpenTelemetry tracing enabled", diagnosticsWriter.ToString(), StringComparison.Ordinal); + } +} 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 + + + + + + + Enable OTLP export + + + Send traces to the configured OTLP endpoint when a new sidecar session starts + + + + + + + + OTLP endpoint + + + + 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/scripts/aspireDashboard.test.ts b/tests/scripts/aspireDashboard.test.ts new file mode 100644 index 0000000..bc28280 --- /dev/null +++ b/tests/scripts/aspireDashboard.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; + +import { + aspireDashboardContainerName, + aspireDashboardImage, + aspireDashboardUrls, + createAspireDashboardRunArgs, + createOpenTelemetryEnvironment, +} from '../../scripts/aspireDashboard'; + +describe('aspire dashboard helpers', () => { + test('creates the official standalone Docker command with mapped OTLP ports', () => { + expect(createAspireDashboardRunArgs()).toEqual([ + 'run', + '--rm', + '-d', + '--name', + aspireDashboardContainerName, + '-p', + '18888:18888', + '-p', + '4317:18889', + '-p', + '4318:18890', + '-e', + 'ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true', + aspireDashboardImage, + ]); + }); + + test('injects OTLP exporter settings for local Aspire development', () => { + expect( + createOpenTelemetryEnvironment( + { + PATH: 'C:\\tools', + }, + aspireDashboardUrls.otlpGrpc, + ), + ).toEqual({ + PATH: 'C:\\tools', + OTEL_EXPORTER_OTLP_ENDPOINT: aspireDashboardUrls.otlpGrpc, + OTEL_EXPORTER_OTLP_PROTOCOL: 'grpc', + }); + }); +}); 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',
+ Export traces from the sidecar to an OTLP-compatible collector +
+ Send traces to the configured OTLP endpoint when a new sidecar session starts +
+ Use bun run aspire to launch the Aspire Dashboard locally at this default endpoint. + Changes take effect on the next sidecar session. +