Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/agent-forge/server/__tests__/observeProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions apps/agent-forge/server/observeProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ObserveView />);
});
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',
Expand Down
18 changes: 17 additions & 1 deletion apps/agent-forge/src/observe/chrome.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -30,11 +34,23 @@ export function AgentSelect({
* panels swap in by themselves once `lousho observe start` runs.
*/
export function ObserveDaemonDown() {
const [target, setTarget] = useState<string | undefined>();
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 (
<div className="observe-empty" role="status">
<p className="observe-empty-title">No data yet - the observe daemon is not reachable.</p>
<p>
Run <code>lousho observe start</code>, then set <code>LOUSHO_OBSERVE=1</code> on your agents; their traces,
Run <code>lousho observe start</code>, then set <code>LOUSHO_OBSERVE={value}</code> on your agents; their traces,
scores and heartbeats show up here automatically.
</p>
</div>
Expand Down
11 changes: 11 additions & 0 deletions apps/agent-forge/src/observe/observeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
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<ObserveTraceList> {
return this.request('/api/v1/traces', { ...filter });
Expand Down
14 changes: 13 additions & 1 deletion src/cli/observe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -42,6 +42,18 @@ async function start(): Promise<ObserveDaemon> {

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);
Expand Down
8 changes: 7 additions & 1 deletion src/cli/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -165,7 +171,7 @@ async function runStart(args: string[]): Promise<number> {
: `lousho observe: bearer auth is ON but no tokens exist yet - create one: lousho observe token create --project <name>${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 = [
Expand Down
Loading