From 773376889bf123062ed887715ab75b3de06b37d8 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 16:55:14 +0300 Subject: [PATCH 1/3] feat(observe): capture message content by default on a local collector Co-Authored-By: Claude Opus 5.5 --- docs/observability.md | 8 ++++++ llms-full.txt | 8 ++++++ src/createAgent.ts | 12 ++++++--- src/createAgentTracing.test.ts | 48 +++++++++++++++++++++++++++++++++- src/observe/emit.test.ts | 11 +++++++- src/observe/emit.ts | 14 ++++++++++ 6 files changed, 96 insertions(+), 5 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 84a80f21..0c0d812d 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -236,6 +236,14 @@ Prompts, model output and tool arguments/results are sensitive and large, so the `gen_ai.*` content attributes are **never recorded by default**. Opt in per agent with `createAgent({ exporter, captureContent: true })`. +The exception is a local collector: when `LOUSHO_OBSERVE` points at loopback +(`1`/`true`, or a URL whose host is `127.0.0.1`, `localhost` or `::1`), +`createAgent` defaults `captureContent` to `true`, so the daemon's trace view +shows the prompt, the model output and the tool arguments and results that +explain a failure. The data stays in the daemon's db on your machine. Turn it +off with `captureContent: false`; a collector beyond loopback keeps the +opt-in default, and `redactContent` still applies. + #### Advanced: the executor API With the executor, opt in per run: diff --git a/llms-full.txt b/llms-full.txt index 2a367a48..add3e8a1 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -13896,6 +13896,14 @@ Prompts, model output and tool arguments/results are sensitive and large, so the `gen_ai.*` content attributes are **never recorded by default**. Opt in per agent with `createAgent({ exporter, captureContent: true })`. +The exception is a local collector: when `LOUSHO_OBSERVE` points at loopback +(`1`/`true`, or a URL whose host is `127.0.0.1`, `localhost` or `::1`), +`createAgent` defaults `captureContent` to `true`, so the daemon's trace view +shows the prompt, the model output and the tool arguments and results that +explain a failure. The data stays in the daemon's db on your machine. Turn it +off with `captureContent: false`; a collector beyond loopback keeps the +opt-in default, and `redactContent` still applies. + #### Advanced: the executor API With the executor, opt in per run: diff --git a/src/createAgent.ts b/src/createAgent.ts index 163f9c5a..dffd0f09 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -20,6 +20,7 @@ import { AgentExecutor, ExecuteOptions, ExecutionResult } from './execution/Agen import { streamResumed, throwingRun, type AgentRun } from './execution/agentRun'; import type { AgentEvent } from './execution/agentEvents'; import type { TraceExporter } from './execution/tracing'; +import { isLoopbackTarget, observeTargetFromEnv } from './observe/emit'; import { withObserveExporter, type PromptUseProvider } from './observe/reporter'; import { parsePromptRef, registryPrompt } from './observe/promptClient'; import { assertModelSettings, mergeModelSettings } from './execution/modelSettings'; @@ -409,8 +410,9 @@ export interface CreateAgentBase agentName, resolvedInstructions.promptUse !== undefined ? { promptUse: resolvedInstructions.promptUse } : {} ); + // Content defaults on when the env collector is this machine's own (the trace + // view cannot explain a failure without it); an explicit setting always wins. + const observeTarget = observeTargetFromEnv(); + const captureContent = config.captureContent ?? (observeTarget && isLoopbackTarget(observeTarget) ? true : undefined); const tracing: Pick = { ...(exporter && { exporter }), - ...(config.captureContent !== undefined && { captureContent: config.captureContent }), + ...(captureContent !== undefined && { captureContent }), ...(config.redactContent !== undefined && { redactContent: config.redactContent }), }; const checkpoints = config.store?.checkpoints; diff --git a/src/createAgentTracing.test.ts b/src/createAgentTracing.test.ts index ff84001b..6cbbb4ef 100644 --- a/src/createAgentTracing.test.ts +++ b/src/createAgentTracing.test.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import type { Tracer } from '@opentelemetry/api'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import { createAgent } from './createAgent'; import { PropagatingToolError } from './execution/AgentExecutor'; @@ -331,3 +331,49 @@ describe('createAgent({ exporter })', () => { }); }); }); + +describe('captureContent default on a local collector', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + const observe = (value: string) => { + vi.stubEnv('LOUSHO_OBSERVE', value); + vi.stubGlobal('fetch', async () => new Response('{}', { status: 200 })); + }; + const chatInput = async (options: { captureContent?: boolean; redactContent?: boolean } = {}) => { + const exporter = recording(); + await createAgent({ provider: mockModel(['Hi.']), exporter, ...options }).send('my key is sk-secret123'); + return exporter.ended.find((span) => op(span) === 'chat')!; + }; + + it('captures content by default when LOUSHO_OBSERVE points at loopback', async () => { + observe('1'); + const chat = await chatInput(); + expect(chat.attributes['gen_ai.input.messages']).toContain('my key is'); + expect(chat.attributes['gen_ai.output.messages']).toContain('Hi.'); + }); + + it('does not capture by default for a non-loopback collector', async () => { + observe('http://collector.test:7777'); + expect((await chatInput()).attributes['gen_ai.input.messages']).toBeUndefined(); + }); + + it('does not capture by default when LOUSHO_OBSERVE is unset', async () => { + vi.stubEnv('LOUSHO_OBSERVE', ''); + expect((await chatInput()).attributes['gen_ai.input.messages']).toBeUndefined(); + }); + + it('captureContent: false wins over the loopback default', async () => { + observe('1'); + expect((await chatInput({ captureContent: false })).attributes['gen_ai.input.messages']).toBeUndefined(); + }); + + it('redactContent still applies to loopback-captured content', async () => { + observe('1'); + const chat = await chatInput({ redactContent: true }); + // The gen_ai.* attributes follow captureContent; the deprecated ones stay redacted. + expect(chat.attributes['gen_ai.input.messages']).toBeDefined(); + expect(chat.attributes.prompt).toBeUndefined(); + }); +}); diff --git a/src/observe/emit.test.ts b/src/observe/emit.test.ts index 1e205d7d..89912052 100644 --- a/src/observe/emit.test.ts +++ b/src/observe/emit.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest'; import type { IngestEvent } from './envelope'; -import { emitObserveEvent, emitObserveEvents, observeTargetFromEnv, resetObserveEnvWarning } from './emit'; +import { emitObserveEvent, emitObserveEvents, isLoopbackTarget, observeTargetFromEnv, resetObserveEnvWarning } from './emit'; const score = (id: string): IngestEvent => ({ type: 'score', @@ -112,3 +112,12 @@ describe('emitObserveEvents', () => { } }); }); + +describe('isLoopbackTarget', () => { + it.each(['http://127.0.0.1:7777', 'http://localhost:1234', 'http://[::1]:7777', 'https://LOCALHOST'])('%s is loopback', (url) => { + expect(isLoopbackTarget({ url })).toBe(true); + }); + it.each(['http://collector.test:7777', 'https://observe.example.com', 'http://0.0.0.0:7777', 'http://localhost.evil.com', 'not a url'])('%s is not', (url) => { + expect(isLoopbackTarget({ url })).toBe(false); + }); +}); diff --git a/src/observe/emit.ts b/src/observe/emit.ts index 3bc3601e..184db5af 100644 --- a/src/observe/emit.ts +++ b/src/observe/emit.ts @@ -76,6 +76,20 @@ export function observeTargetFromEnv(env: NodeJS.ProcessEnv | undefined = typeof }; } +/** + * Whether `target` is this machine's own collector (host `127.0.0.1`, + * `localhost` or `::1`). A loopback collector is auth-free and its db never + * leaves the machine, so content capture defaults on for it. + */ +export function isLoopbackTarget(target: ObserveTarget): boolean { + try { + const host = new URL(target.url).hostname.toLowerCase(); + return host === '127.0.0.1' || host === 'localhost' || host === '[::1]'; + } catch { + return false; + } +} + export interface EmitObserveOptions { /** Explicit target; default the env's (`observeTargetFromEnv`). */ target?: ObserveTarget; From 7d75fe82043d5a1e18d9f481023b280bd4f542d6 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 16:59:07 +0300 Subject: [PATCH 2/3] test(observe): make the loopback redaction test falsifiable; clarify redactContent docs Co-Authored-By: Claude Opus 5.5 --- docs/observability.md | 7 ++++++- llms-full.txt | 7 ++++++- src/createAgentTracing.test.ts | 35 +++++++++++++++++++++++++++++++--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 0c0d812d..3e841177 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -242,7 +242,12 @@ The exception is a local collector: when `LOUSHO_OBSERVE` points at loopback shows the prompt, the model output and the tool arguments and results that explain a failure. The data stays in the daemon's db on your machine. Turn it off with `captureContent: false`; a collector beyond loopback keeps the -opt-in default, and `redactContent` still applies. +opt-in default. The default applies to agents built with `createAgent`. + +`redactContent` keeps only the legacy `input`/`prompt`/`args`/`result` +attributes off; it does not scrub `gen_ai.*` message content. On a loopback +collector the message text, including any secret typed into a prompt, is +stored in the local db, so set `captureContent: false` if that is not wanted. #### Advanced: the executor API diff --git a/llms-full.txt b/llms-full.txt index add3e8a1..ab7533c2 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -13902,7 +13902,12 @@ The exception is a local collector: when `LOUSHO_OBSERVE` points at loopback shows the prompt, the model output and the tool arguments and results that explain a failure. The data stays in the daemon's db on your machine. Turn it off with `captureContent: false`; a collector beyond loopback keeps the -opt-in default, and `redactContent` still applies. +opt-in default. The default applies to agents built with `createAgent`. + +`redactContent` keeps only the legacy `input`/`prompt`/`args`/`result` +attributes off; it does not scrub `gen_ai.*` message content. On a loopback +collector the message text, including any secret typed into a prompt, is +stored in the local db, so set `captureContent: false` if that is not wanted. #### Advanced: the executor API diff --git a/src/createAgentTracing.test.ts b/src/createAgentTracing.test.ts index 6cbbb4ef..179a3317 100644 --- a/src/createAgentTracing.test.ts +++ b/src/createAgentTracing.test.ts @@ -369,11 +369,40 @@ describe('captureContent default on a local collector', () => { expect((await chatInput({ captureContent: false })).attributes['gen_ai.input.messages']).toBeUndefined(); }); - it('redactContent still applies to loopback-captured content', async () => { + it('redactContent: false records the deprecated prompt next to the loopback-captured content', async () => { + observe('1'); + const chat = await chatInput({ redactContent: false }); + expect(chat.attributes['gen_ai.input.messages']).toContain('my key is'); + expect(chat.attributes.prompt).toBeDefined(); + }); + + it('redactContent: true keeps the deprecated prompt off while gen_ai content is still captured', async () => { observe('1'); const chat = await chatInput({ redactContent: true }); - // The gen_ai.* attributes follow captureContent; the deprecated ones stay redacted. - expect(chat.attributes['gen_ai.input.messages']).toBeDefined(); + expect(chat.attributes['gen_ai.input.messages']).toContain('my key is'); expect(chat.attributes.prompt).toBeUndefined(); }); + + it('resume() captures content by default on loopback', async () => { + observe('1'); + const store = memoryStore(); + let crashed = false; + const flaky = defineTool({ + name: 'flaky', + description: 'Dies once', + input: z.object({}), + execute: async () => { + if (!crashed) { + crashed = true; + throw new PropagatingToolError('process died'); + } + return 'ok'; + }, + }); + const crashing = createAgent({ provider: mockModel([{ toolCalls: [{ name: 'flaky', id: 'c1' }] }]), tools: [flaky], store }); + await expect(crashing.send('Go', { sessionId: 'job' })).rejects.toThrow('process died'); + const exporter = recording(); + await createAgent({ provider: mockModel(['Done.']), tools: [flaky], store, exporter }).resume('job'); + expect(exporter.ended.find((span) => op(span) === 'chat')!.attributes['gen_ai.output.messages']).toContain('Done.'); + }); }); From b1d1141fd97913d5ac662b048956a519a7ea7ccc Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 17:42:37 +0300 Subject: [PATCH 3/3] refactor(observe): extract the loopback captureContent default into a helper Keeps createAgent under the fallow complexity limit; behaviour unchanged. Co-Authored-By: Claude Opus 5.5 --- src/createAgent.ts | 5 ++--- src/observe/emit.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/createAgent.ts b/src/createAgent.ts index dffd0f09..cee76a7b 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -20,7 +20,7 @@ import { AgentExecutor, ExecuteOptions, ExecutionResult } from './execution/Agen import { streamResumed, throwingRun, type AgentRun } from './execution/agentRun'; import type { AgentEvent } from './execution/agentEvents'; import type { TraceExporter } from './execution/tracing'; -import { isLoopbackTarget, observeTargetFromEnv } from './observe/emit'; +import { defaultCaptureContent } from './observe/emit'; import { withObserveExporter, type PromptUseProvider } from './observe/reporter'; import { parsePromptRef, registryPrompt } from './observe/promptClient'; import { assertModelSettings, mergeModelSettings } from './execution/modelSettings'; @@ -1074,8 +1074,7 @@ export function createAgent ); // Content defaults on when the env collector is this machine's own (the trace // view cannot explain a failure without it); an explicit setting always wins. - const observeTarget = observeTargetFromEnv(); - const captureContent = config.captureContent ?? (observeTarget && isLoopbackTarget(observeTarget) ? true : undefined); + const captureContent = defaultCaptureContent(config.captureContent); const tracing: Pick = { ...(exporter && { exporter }), ...(captureContent !== undefined && { captureContent }), diff --git a/src/observe/emit.ts b/src/observe/emit.ts index 184db5af..b45f5580 100644 --- a/src/observe/emit.ts +++ b/src/observe/emit.ts @@ -90,6 +90,17 @@ export function isLoopbackTarget(target: ObserveTarget): boolean { } } +/** + * The `captureContent` an agent runs with: the explicit setting when there is + * one, else on when the env collector is loopback, else `undefined` (the + * executor then follows the OTel content env var). + */ +export function defaultCaptureContent(explicit: boolean | undefined, env?: NodeJS.ProcessEnv): boolean | undefined { + if (explicit !== undefined) return explicit; + const target = observeTargetFromEnv(env); + return target && isLoopbackTarget(target) ? true : undefined; +} + export interface EmitObserveOptions { /** Explicit target; default the env's (`observeTargetFromEnv`). */ target?: ObserveTarget;