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 collector
Aspire Dashboard]
OS[Native windowing
and 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 a7c6ef6..3eea796 100644
--- a/README.md
+++ b/README.md
@@ -92,11 +92,17 @@ 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
```
+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.
## Trademarks
diff --git a/package.json b/package.json
index 453009a..fc6b648 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/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',
+ });
+ });
+});