From ad9e27f5f0a8f4199abe0db042c1ac527e371418 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 17:03:28 +0300 Subject: [PATCH] fix(observe): show the right LOUSHO_OBSERVE value for non-default ports Co-Authored-By: Claude Opus 5.5 --- .../server/__tests__/observeProxy.test.ts | 6 ++++++ apps/agent-forge/server/observeProxy.ts | 5 +++++ .../observe/__tests__/ObserveView.test.tsx | 21 +++++++++++++++++++ apps/agent-forge/src/observe/chrome.tsx | 18 +++++++++++++++- apps/agent-forge/src/observe/observeClient.ts | 11 ++++++++++ src/cli/observe.test.ts | 14 ++++++++++++- src/cli/observe.ts | 8 ++++++- 7 files changed, 80 insertions(+), 3 deletions(-) diff --git a/apps/agent-forge/server/__tests__/observeProxy.test.ts b/apps/agent-forge/server/__tests__/observeProxy.test.ts index 728993de..aa026b46 100644 --- a/apps/agent-forge/server/__tests__/observeProxy.test.ts +++ b/apps/agent-forge/server/__tests__/observeProxy.test.ts @@ -60,6 +60,12 @@ describe('observe proxy', () => { expect(res.body.echoed).toBe('/api/v1/traces?status=error&limit=10'); }); + it('answers GET /observe/__target with the daemon url the proxy points at', async () => { + const res = await request(app).get('/observe/__target'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ url: daemonUrl }); + }); + it('answers 502 with the start hint when the daemon is down', async () => { daemon?.close(); daemon = undefined; diff --git a/apps/agent-forge/server/observeProxy.ts b/apps/agent-forge/server/observeProxy.ts index 55832d52..309234ff 100644 --- a/apps/agent-forge/server/observeProxy.ts +++ b/apps/agent-forge/server/observeProxy.ts @@ -28,6 +28,11 @@ export function registerObserveProxy(app: Express): void { res.status(405).json({ error: 'The observe proxy only forwards GET reads.' }); return; } + // Which daemon this proxy points at, so the down state can name the right LOUSHO_OBSERVE value. + if (req.path === '/__target') { + res.json({ url: observeTarget() }); + return; + } const upstream = `${observeTarget()}${req.originalUrl.slice(PROXY_PREFIX.length)}`; let response: Response; try { diff --git a/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx b/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx index ae5ab783..5a0e4b6f 100644 --- a/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx +++ b/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx @@ -41,6 +41,27 @@ describe('ObserveView', () => { expect(container.querySelector('[role="status"]')).not.toBeNull(); }); + it('names the proxy target in the down hint when the daemon is on another port', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).endsWith('/__target')) { + return new Response(JSON.stringify({ url: 'http://127.0.0.1:7795' }), { status: 200 }); + } + throw new TypeError('fetch failed'); + }) + ); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root!.render(); + }); + await flush(); + await flush(); + expect(container.textContent).toContain('LOUSHO_OBSERVE=http://127.0.0.1:7795'); + }); + it('renders the trace list once the daemon answers', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/agent-forge/src/observe/chrome.tsx b/apps/agent-forge/src/observe/chrome.tsx index 24b5c4a3..3cf2ff43 100644 --- a/apps/agent-forge/src/observe/chrome.tsx +++ b/apps/agent-forge/src/observe/chrome.tsx @@ -1,7 +1,11 @@ /** Shared bits of the observe panels' chrome: the daemon-down empty state, inline API errors, status pills, filter controls. */ +import { useEffect, useState } from 'react'; +import { observeClient } from './observeClient'; import type { ObserveAgentRow } from './types'; +const DEFAULT_OBSERVE_URL = 'http://127.0.0.1:7777'; + /** The "All agents" filter select the panels share. */ export function AgentSelect({ agents, @@ -30,11 +34,23 @@ export function AgentSelect({ * panels swap in by themselves once `lousho observe start` runs. */ export function ObserveDaemonDown() { + const [target, setTarget] = useState(); + useEffect(() => { + let live = true; + void observeClient.target().then((url) => { + if (live) setTarget(url); + }); + return () => { + live = false; + }; + }, []); + // `=1` only reaches the default daemon; any other target must be spelled out. + const value = target === undefined || target.replace(/\/+$/, '') === DEFAULT_OBSERVE_URL ? '1' : target; return (

No data yet - the observe daemon is not reachable.

- Run lousho observe start, then set LOUSHO_OBSERVE=1 on your agents; their traces, + Run lousho observe start, then set LOUSHO_OBSERVE={value} on your agents; their traces, scores and heartbeats show up here automatically.

diff --git a/apps/agent-forge/src/observe/observeClient.ts b/apps/agent-forge/src/observe/observeClient.ts index 3c8c3b81..fd13207b 100644 --- a/apps/agent-forge/src/observe/observeClient.ts +++ b/apps/agent-forge/src/observe/observeClient.ts @@ -139,6 +139,17 @@ export class ObserveClient { } } + /** The daemon URL reporters should use: the proxy's `__target` (same-origin), or the configured absolute base. `undefined` if unknown. */ + async target(): Promise { + if (!this.baseUrl.startsWith('/')) return this.baseUrl; + try { + const body = await this.request<{ url?: unknown }>('/__target'); + return typeof body.url === 'string' ? body.url : undefined; + } catch { + return undefined; + } + } + /** `GET /api/v1/traces` - the paginated, filterable trace list. */ listTraces(filter: TraceListFilter = {}): Promise { return this.request('/api/v1/traces', { ...filter }); diff --git a/src/cli/observe.test.ts b/src/cli/observe.test.ts index 52c6381a..7b23d36b 100644 --- a/src/cli/observe.test.ts +++ b/src/cli/observe.test.ts @@ -11,7 +11,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { defaultPidPath, startObserveDaemon, type ObserveDaemon } from '../observe/daemon'; -import { runObserve } from './observe'; +import { reporterHint, runObserve } from './observe'; const daemons: ObserveDaemon[] = []; const servers: http.Server[] = []; @@ -42,6 +42,18 @@ async function start(): Promise { const dbPath = (): string => path.join(cwd, 'observe.db'); +describe('reporterHint', () => { + it('says LOUSHO_OBSERVE=1 only for the default loopback port', () => { + expect(reporterHint('127.0.0.1', 7777)).toContain('LOUSHO_OBSERVE=1 '); + }); + + it('names the base url on a non-default port or host', () => { + expect(reporterHint('127.0.0.1', 7790)).toContain('LOUSHO_OBSERVE=http://127.0.0.1:7790'); + expect(reporterHint('127.0.0.1', 7790)).not.toContain('LOUSHO_OBSERVE=1'); + expect(reporterHint('localhost', 7777)).toContain('LOUSHO_OBSERVE=http://localhost:7777'); + }); +}); + describe('lousho observe', () => { it('prints help (0 with --help, 2 with none) and exits 2 on an unknown command', async () => { expect(await runObserve([])).toBe(2); diff --git a/src/cli/observe.ts b/src/cli/observe.ts index 61446e30..94c7c199 100644 --- a/src/cli/observe.ts +++ b/src/cli/observe.ts @@ -113,6 +113,12 @@ function envPort(): number { return Number.isInteger(port) && port >= 0 && port <= 65535 ? port : OBSERVE_DEFAULT_PORT; } +/** The banner line telling agents how to reach this daemon: `=1` only reaches the default loopback port. */ +export function reporterHint(host: string, port: number): string { + const value = host === '127.0.0.1' && port === OBSERVE_DEFAULT_PORT ? '1' : `http://${host}:${port}`; + return `lousho observe: agents emit with LOUSHO_OBSERVE=${value} (no auth on loopback).`; +} + /* ------------------------------------------------------------------- start */ /** `start` hit EADDRINUSE: say who owns the port. 0 = this db's daemon already runs; 1 = anything else. */ @@ -165,7 +171,7 @@ async function runStart(args: string[]): Promise { : `lousho observe: bearer auth is ON but no tokens exist yet - create one: lousho observe token create --project ${dbPath === defaultDbPath() ? '' : ` --db ${dbPath}`}` ); } else { - console.log('lousho observe: agents emit with LOUSHO_OBSERVE=1 (no auth on loopback).'); + console.log(reporterHint(host, daemon.port)); } if (daemon.alerts.webhooks.length > 0 || daemon.alerts.errorRateThreshold !== undefined) { const parts = [