From c816167c4019605ab7f2cf6279ceefebf5c596d0 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 15:01:18 +0800 Subject: [PATCH 01/21] docs: add observability instrumentation design for moss-drobotics Re-instrument D:\moss-drobotics with OpenTelemetry, correcting the tracing/metrics mixed-mode tech debt from the from-remote fork: tracing moves onto the official SDK so trace and metric share one Resource, errors go through recordException, and spans are batched. Local file trace is preserved as a FileSpanProcessor alongside the OTLP exporter. Co-Authored-By: Claude --- ...16-observability-instrumentation-design.md | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-observability-instrumentation-design.md diff --git a/docs/superpowers/specs/2026-07-16-observability-instrumentation-design.md b/docs/superpowers/specs/2026-07-16-observability-instrumentation-design.md new file mode 100644 index 00000000..e92a21df --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-observability-instrumentation-design.md @@ -0,0 +1,361 @@ +--- +title: Moss-Drobotics 可观测性(埋点)重新设计 +status: draft +date: 2026-07-16 +owner: tongchun.zhao +--- + +# 重新埋点:D:\moss-drobotics 可观测性设计 + +## 1. 背景与目标 + +`D:\moss-drobotics` 是 `D-Robotics/moss` 最新代码的克隆,目前**没有任何运行时可观测性**:LLM 调用、工具执行、session 这些核心环节既不发 trace 也不发 metric,出问题只能靠日志盲猜。 + +参考实现 `D:\moss-from-remote`(用户的 fork)已经加了完整的 OTel 埋点,能发 trace+metric 到本地 receiver(端口 4318)并在 3000 面板展示。但它有一处技术债:**tracing 手写 OTLP/JSON(`otel-bridge.ts`,刻意零 SDK 依赖),metrics 却用了官方 `@opentelemetry/sdk-metrics`**——注释自己立的「不引入 SDK」原则被 metrics 打破,导致 trace 与 metric 各造一套 Resource、两套发送逻辑。 + +本设计目标:**从头为 moss-drobotics 埋点,纠正混搭债,同时保留本地文件 trace**。不是把 from-remote 原样搬过来。 + +### 设计原则 + +1. **一处 SDK**:tracing 与 metrics 同源,共享一个 Resource、一个 NodeSDK 实例。消除 from-remote 的混搭。 +2. **本地文件 trace 保留**:作为独立 SpanProcessor 落盘,与 OTLP exporter 并存(不是 from-remote 那套手写 tracer + 单独 exportSpan)。 +3. **YAGNI**:不搬 `ab-testing.ts`(A/B 测试不属可观测性)、不搬 from-remote 的 `trace-exporter.ts` 老实现(它假设了手写 tracer 的 SerializedSpan 形状,SDK 模式下重写为 SpanProcessor)。 +4. **error 走 recordException**:异常自动记成 span event,redact 仍套在 message 上防敏感信息泄露。 +5. **优雅 flush**:进程退出前 flush 残留 span/metric,不丢末批数据。 +6. **零改动业务语义**:埋点不改变任何现有控制流,关闭时零开销(noop 桩)。 + +## 2. 架构 + +### 2.1 三层 span 结构 + +贴合 agent 真实调用栈(drobotics 实际代码:`moss-agent.chat()` → `agent-loop` 主循环 → `executeLlmTurn` / `processLlmResponse` → `executeOneToolCall`): + +``` +moss.session ← moss-agent.chat() 整个用户回合(根 span,1 个/用户消息) +├─ moss.agent.turn ← agent-loop 主循环每轮迭代(N 个/session) +│ ├─ moss.llm.request ← executeLlmTurn 里单次 LLM 调用 +│ └─ moss.tool.invoke ← executeOneToolCall 里单次工具执行(可并行) +``` + +上下文传播用 OTel SDK 自带的 `context` + `AsyncLocalStorage`(SDK 内部已实现),**业务代码不需要手动透传 `parentSpan` 参数**。这是对 from-remote 那套「到处传 parentSpan」的简化——手写桥接才需要手动透传,SDK 模式下子 span 自动继承当前 context 的父 span。 + +### 2.2 数据流 + +``` +业务调用点 (withSpan / mossMetrics.xxx) + │ + ▼ +TracerProvider / MeterProvider (NodeSDK 装配) + ├── BatchSpanProcessor ──► OTLPTraceExporter ──► http://localhost:4318/v1/traces + ├── FileSpanProcessor ──► traces.jsonl 落盘 (本地文件 trace,新增) + └── PeriodicMetricReader ─► OTLPMetricExporter ──► http://localhost:4318/v1/metrics + │ + ▼ + 本地 receiver (D:\otel) + 面板 localhost:3000 +``` + +三个 exporter 共享同一个 Resource(`service.name` + `service.version` + `process.*`),后端看到的 trace、metric、本地文件 resource 属性完全一致。 + +## 3. 文件组织 + +``` +packages/moss-agent/src/observability/ + index.ts ← 公共入口:initObservability / shutdownObservability / withSpan / mossMetrics + sdk.ts ← NodeSDK 装配、Resource、三个 exporter/processor 集中一处 + tracing.ts ← withSpan 包装、attributes 构造器(turn/tool/llm/session)、Tracer handle + metrics.ts ← mossMetrics 句柄 + instruments 定义 + file-trace.ts ← FileSpanProcessor + JSONL 落盘 + getStats 聚合(重写自 trace-exporter) + redact.ts ← 保留(drobotics 已有,与 from-remote 一致,不动) + llm-usage.ts ← 保留(drobotics 已有,本地 usage 日志,与埋点无关,不动) +``` + +对比 from-remote:砍掉 `otel-bridge.ts`(手写 OTLP/JSON,被 SDK 取代)、`ab-testing.ts`(非可观测性);`trace-exporter.ts` 重写为 `file-trace.ts`(从「手写 tracer 的 SerializedSpan 落盘」改为「SDK SpanProcessor 落盘」,形状对齐 SDK 的 `ReadableSpan`)。 + +## 4. 组件设计 + +### 4.1 `sdk.ts` —— SDK 装配 + +单一初始化点。tracing 与 metrics 同一个 NodeSDK,共享 Resource。 + +```typescript +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { BatchSpanProcessor, SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { semconv } from '@opentelemetry/semantic-conventions'; +import { FileSpanProcessor } from './file-trace.js'; + +export interface ObservabilityConfig { + serviceName: string; + otlpUrl: string; // receiver 根地址,如 http://localhost:4318 + enabled: boolean; // tracing 开关 + metricsEnabled: boolean; + fileTraceEnabled: boolean; + workspaceDir: string; // 落盘到 {workspaceDir}/.moss/analytics/traces.jsonl +} + +let sdk: NodeSDK | null = null; + +export function initObservabilitySdk(cfg: ObservabilityConfig): void { + if (!cfg.enabled) return; + const pkg = readPackageVersion(); // 读 packages/moss-agent/package.json 的 version + const resource = resourceFromAttributes({ + [semconv.ATTR_SERVICE_NAME]: cfg.serviceName, + [semconv.ATTR_SERVICE_VERSION]: pkg, // 当前 0.5.3,但不硬编码 + }); + + const spanProcessors: SpanProcessor[] = [new BatchSpanProcessor( + new OTLPTraceExporter({ url: `${cfg.otlpUrl}/v1/traces` }) + )]; + if (cfg.fileTraceEnabled) { + spanProcessors.push(new FileSpanProcessor(cfg.workspaceDir)); + } + + const readers = cfg.metricsEnabled ? [new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${cfg.otlpUrl}/v1/metrics` }), + exportIntervalMillis: 10_000, + })] : []; + + // NodeSDK 的 metricReader 选项接单个 MetricReader;若需要多 reader, + // 用底层 MeterProvider({ resource, readers }) 自行装配(from-remote 即如此)。 + // 本设计单 metric reader,走 NodeSDK 即可: + sdk = new NodeSDK({ + resource, + spanProcessors, + metricReader: readers[0], // 单数;readers 为空时 undefined,metrics 关闭 + }); + sdk.start(); +} + +export async function shutdownObservabilitySdk(): Promise { + if (!sdk) return; + await sdk.shutdown(); // flush 残留 span/metric + sdk = null; +} +``` + +### 4.2 `tracing.ts` —— withSpan 与 attributes + +```typescript +import { trace, context, SpanStatusCode } from '@opentelemetry/api'; +import { errorMessage } from '../errors.js'; +import { redactSensitiveData } from './redact.js'; + +const tracer = trace.getTracer('moss-agent'); + +export async function withSpan( + name: string, + attributes: Record | undefined, + fn: (span: Span) => Promise, +): Promise { + const span = tracer.startSpan(name, { attributes }); + return context.with(trace.setSpan(context.active(), span), async () => { + try { + const result = await fn(span); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (err) { + span.recordException(err); // 异常自动记成 event:type/message/stack + span.setStatus({ + code: SpanStatusCode.ERROR, + message: redactSensitiveData(errorMessage(err)), // message 上 redact + }); + throw err; + } finally { + span.end(); + } + }); +} + +// attributes 构造器:集中定义 span 维度,避免散落 +export const sessionAttributes = (runId: string, model: string, sessionKey: string) => + ({ runId, model, sessionKey }); +export const turnAttributes = (runId: string, turn: number, model: string) => + ({ runId, turn, model }); +export const llmAttributes = (runId: string, model: string, inputTokens: number) => + ({ runId, model, inputTokens }); +export const toolAttributes = (runId: string, toolName: string, toolCallId: string) => + ({ runId, toolName, toolCallId }); +``` + +注意:drobotics 现有 `agent-loop-llm-call.ts:133` 已经在用 `withSpan('agent.llm_turn', turnAttributes(...))`。迁移时 span 名从 `agent.llm_turn` 改为 `moss.llm.request`(命名规范统一),attributes 构造器签名保持不变以最小化调用点改动。 + +### 4.3 `metrics.ts` —— instruments + +```typescript +import { metrics } from '@opentelemetry/api'; + +const meter = metrics.getMeter('moss-agent'); + +export const mossMetrics = { + // LLM + llmTokens: meter.createCounter('moss.llm.tokens', { unit: '{token}' }), + llmDuration: meter.createHistogram('moss.llm.request.duration', { unit: 'ms' }), + // tool + toolInvocations: meter.createCounter('moss.tool.invocations'), + toolDuration: meter.createHistogram('moss.tool.invoke.duration', { unit: 'ms' }), + // session + sessionCount: meter.createCounter('moss.session.count'), + sessionDuration: meter.createHistogram('moss.session.duration', { unit: 'ms' }), + sessionToolCount: meter.createHistogram('moss.session.tool_count'), // 纠正错位:每轮工具数,不是 turns +}; +``` + +SDK 的 `meter` 在未 `setGlobalMeterProvider` 时返回 noop meter,instruments 都是 noop——关闭即零开销,业务代码无条件调用 `.add()`/`.record()`,与 from-remote 的 noop 模式一致。 + +### 4.4 `file-trace.ts` —— 本地文件 trace(SpanProcessor 重写) + +from-remote 的 `TraceFileExporter` 假设手写 tracer 产出的 `SerializedSpan`。SDK 模式下重写为 `SpanProcessor`,消费 SDK 的 `ReadableSpan`: + +```typescript +import { SpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const FLUSH_INTERVAL_MS = 30_000; + +export class FileSpanProcessor implements SpanProcessor { + private buffer: ReadableSpan[] = []; + private timer: ReturnType | null = null; + private file: string; + + constructor(workspaceDir: string) { + this.file = path.join(workspaceDir, '.moss', 'analytics', 'traces.jsonl'); + this.timer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS); + } + + onStart(): void {} // 无需处理 + onEnd(span: ReadableSpan): void { this.buffer.push(span); } + + async flush(): Promise { + if (this.buffer.length === 0) return; + const snapshot = this.buffer.splice(0); + const lines = snapshot.map(serializeSpan).join('\n') + '\n'; + try { + await fs.mkdir(path.dirname(this.file), { recursive: true }); + await fs.appendFile(this.file, lines, 'utf-8'); + } catch { /* never block agent */ } + } + + async shutdown(): Promise { + if (this.timer) clearInterval(this.timer); + await this.flush(); + } + async forceFlush(): Promise { await this.flush(); } +} +``` + +`serializeSpan` 把 `ReadableSpan` 拍平成 JSONL 一行(name / startTimeUnixNano / endTimeUnixNano / attributes / events / status),形状对齐 from-remote 的 `SerializedSpan` 便于复用其 `getStats` 聚合逻辑(保留 stats 面板能力)。 + +### 4.5 `index.ts` —— 公共入口与初始化 + +```typescript +import { initObservabilitySdk, shutdownObservabilitySdk } from './sdk.js'; + +export interface InitOptions { + workspaceDir: string; + serviceName?: string; + otlpUrl?: string; +} + +export function initObservability(opts: InitOptions): void { + const enabled = process.env.MOSS_OTEL_ENABLED === '1' || !!process.env.MOSS_OTEL_URL; + if (!enabled) return; // noop + initObservabilitySdk({ + serviceName: process.env.MOSS_OTEL_SERVICE_NAME ?? opts.serviceName ?? 'moss', + otlpUrl: process.env.MOSS_OTEL_URL ?? opts.otlpUrl ?? 'http://localhost:4318', + enabled: true, + // tracing 开则 metrics 默认开(纠正 from-remote 两个开关易漏配) + metricsEnabled: process.env.MOSS_METRICS_ENABLED !== '0', + // 本地文件 trace:默认开(用户要求保留),MOSS_FILE_TRACE=0 关 + fileTraceEnabled: process.env.MOSS_FILE_TRACE !== '0', + workspaceDir: opts.workspaceDir, + }); +} + +export { shutdownObservabilitySdk as shutdownObservability } from './sdk.js'; +export { withSpan, sessionAttributes, turnAttributes, llmAttributes, toolAttributes } from './tracing.js'; +export { mossMetrics } from './metrics.js'; +export { FileSpanProcessor } from './file-trace.js'; +``` + +`redact.ts`、`llm-usage.ts` 的导出从 drobotics 现有 `index.ts` 保留。 + +## 5. 调用点(5 处业务 + 1 处工具函数) + +| # | 文件 | 埋什么 | drobotics 现状 | +|---|------|--------|----------------| +| 1 | `cli-main.ts` | `initObservability()` 一次(agent 创建前);退出时 `shutdownObservability()` | 仅有 `setTracer('console')` | +| 2 | `core/agent/moss-agent.ts` | `chat()` 开 `moss.session` 根 span + session metrics(count/duration/tool_count) | 无 | +| 3 | `core/loop/agent-loop.ts` | 主循环每轮迭代外包 `moss.agent.turn` span(新增这层) | 无 | +| 4 | `core/loop/agent-loop-llm-call.ts` | `withSpan('moss.llm.request')` + llm metrics | 已有 `withSpan('agent.llm_turn')`,需改名+加 metrics | +| 5 | `core/tools/execute-tool-call.ts` | `withSpan('moss.tool.invoke')` + tool metrics | 无 | +| 6 | `tools/web-fetch.ts` + `web-search.ts` | `propagation.inject(headers)` 注入 traceparent(收敛到一个工具函数) | 无 | + +对比 from-remote:少一处「手动透传 parentSpan」(SDK 自动传播);web-fetch/web-search 的 `injectTraceparent` 改用 SDK `propagation.inject`。 + +## 6. 环境变量 + +| 变量 | 默认 | 作用 | +|------|------|------| +| `MOSS_OTEL_ENABLED` | unset=关 | 总开关(设 `1` 或配 `MOSS_OTEL_URL` 即开) | +| `MOSS_OTEL_URL` | `http://localhost:4318` | OTLP 根地址 | +| `MOSS_OTEL_SERVICE_NAME` | `moss` | service.name | +| `MOSS_METRICS_ENABLED` | tracing 开时默认开 | 设 `0` 单独关 metrics | +| `MOSS_FILE_TRACE` | 开 | 设 `0` 关本地文件 trace | +| `MOSS_TRACE_SAMPLE_RATIO` | 1.0 | 采样率(SDK TailSampling 或 ParentBased,初版可用 alwaysOn) | + +对比 from-remote:去掉了需要同时设 `MOSS_OTEL_ENABLED` + `MOSS_METRICS_ENABLED` 的冗余(tracing 开则 metrics 默认开)。 + +## 7. 依赖 + +`packages/moss-agent/package.json` 新增(drobotics 当前一个 OTel 包都没有): + +```json +"@opentelemetry/api": "^1.9.0", +"@opentelemetry/sdk-node": "^0.200.0", +"@opentelemetry/sdk-trace-base": "^0.200.0", +"@opentelemetry/sdk-metrics": "^2.9.0", +"@opentelemetry/exporter-trace-otlp-http": "^0.200.0", +"@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", +"@opentelemetry/resources": "^2.9.0", +"@opentelemetry/semantic-conventions": "^1.43.0" +``` + +**版本对齐策略(关键,避免歧义)**:OTel JS 分两条版本线——experimental `0.x`(`sdk-node` / `sdk-trace-*` / `exporter-trace-*`,仍带 `-experimental` tag)与 stable `1.x`/`2.x`(`api` / `sdk-metrics` / `resources` / `semantic-conventions`)。两者可共存——from-remote 已用 `api@1.9 + sdk-metrics@2.9 + exporter-metrics-otlp-http@0.220` 验证可跑。本设计补充 tracing 的 experimental 包,与 metrics 现有版本线各自向前对齐:tracing 侧统一 `^0.200`,metrics 侧沿用 from-remote 的 `^2.9` / `^0.220`。 + +实现时以 `npm install` 实际解析的版本为准,不锁死补丁号;若 `sdk-node@0.200` 与 `sdk-metrics@2.9` 出现 peer 冲突,优先把 tracing experimental 包升到与 `exporter-metrics-otlp-http@0.220` 同一 experimental 线(`^0.220`),保持 experimental 包彼此对齐。 + +## 8. 关闭即零开销 + +- 未 `setGlobalMeterProvider` 时,`metrics.getMeter()` 返回 noop meter,所有 instrument 是 noop——`.add()`/`.record()` 无条件调用零成本。 +- `trace.getTracer()` 返回的 tracer 在未注册 provider 时是 noop tracer,`startSpan` 返回 noop span。 +- `initObservability()` 在 `enabled=false` 时直接 return,不启动任何 timer/SDK。 +- 业务代码不写任何 `if (tracingEnabled)` 分支——全靠 noop 桩。 + +## 9. 错误处理 + +- `initObservability` 失败(receiver 连不上、SDK 构造异常)只记一行 log,不抛——监控是 best-effort。 +- `withSpan` 里业务抛错时 `recordException` + `setStatus(ERROR)`,再原样 rethrow——埋点不吞错误。 +- `FileSpanProcessor.flush` 落盘失败静默——不阻塞 agent。 +- 进程退出:`process.on('beforeExit', shutdownObservability)` 兜底 flush;CLI 正常退出路径也显式调用。 + +## 10. 验证 + +1. `npm install` 装齐依赖后 `npm run -w @rdk-moss/agent build` 通过(TS 类型对齐)。 +2. `MOSS_OTEL_ENABLED=1 node packages/moss-agent/dist/cli.js` 跑一次对话,确认: + - receiver(4318)收到 trace(session→turn→llm/tool 三层 span 在面板能展开成树)。 + - 4318 收到 metrics(token/duration/invocation 计数)。 + - `.moss/analytics/traces.jsonl` 有落盘内容,行数 = span 数。 +3. 不设任何 env 跑一次,确认无报错、无额外开销、无文件产生。 +4. 关掉 receiver 跑一次,确认 agent 正常工作(fire-and-forget)。 + +## 11. 不做(YAGNI) + +- 不搬 `ab-testing.ts`——A/B 测试是实验框架,与可观测性无关。 +- 不做分布式 context 跨进程传播(moss 是单进程 CLI,W3C traceparent 注入出站 HTTP 已够)。 +- 不做 TailSampling——本地单机,alwaysOn 采样即可,`MOSS_TRACE_SAMPLE_RATIO` 预留但初版不接。 +- 不做 metrics 语义到 OTel GenAI semconv 的完整对齐(那是另一项工作),只保证命名清晰、单位正确。 From d2cb917d32fe8ed90734b0729d97827ab8b5a507 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 16:01:57 +0800 Subject: [PATCH 02/21] docs: add observability instrumentation implementation plan 13-task TDD plan implementing the 2026-07-16 observability design: deps, metrics handle, SDK-backed withSpan, FileSpanProcessor, NodeSDK assembly, trace-context injection, and the 6 call-site wirings (session/turn/llm/tool spans + metrics, web traceparent), ending with end-to-end verification against the local OTLP receiver. Co-Authored-By: Claude --- ...026-07-16-observability-instrumentation.md | 1351 +++++++++++++++++ 1 file changed, 1351 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-observability-instrumentation.md diff --git a/docs/superpowers/plans/2026-07-16-observability-instrumentation.md b/docs/superpowers/plans/2026-07-16-observability-instrumentation.md new file mode 100644 index 00000000..62bb206e --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-observability-instrumentation.md @@ -0,0 +1,1351 @@ +# Moss-Drobotics Observability (埋点) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add OpenTelemetry tracing + metrics to `D:\moss-drobotics` so LLM calls, tool execution, and sessions emit spans/metrics to a local OTLP receiver and to a local JSONL trace file. + +**Architecture:** Single `@opentelemetry/sdk-node` NodeSDK instance shared by tracing and metrics (one Resource, one SDK). A custom `FileSpanProcessor` mirrors spans to `.moss/analytics/traces.jsonl` alongside the OTLP trace exporter. Three span layers: `moss.session` → `moss.agent.turn` → `moss.llm.request` / `moss.tool.invoke`. Context propagation is SDK-native (no manual `parentSpan` threading). When disabled, all instruments are noop — zero overhead. + +**Tech Stack:** TypeScript ESM monorepo (`@rdk-moss/agent`), `@opentelemetry/*` (api 1.x + experimental 0.x + metrics 2.x), `node:assert/strict` `.spec.mjs` tests run against built `dist/`. + +## Global Constraints + +- Package: `@rdk-moss/agent`, source at `packages/moss-agent/src/`. ESM (`"type": "module"`), `.js` import specifiers in TS. +- Tests are `*.spec.mjs` files using `node:assert/strict`, executed against compiled `dist/` via `node packages/moss-agent/test/.spec.mjs` (or `npm test -w @rdk-moss/agent` which builds then runs `scripts/run-package-tests.mjs`). +- No vitest. Every test task must: write `test/.spec.mjs`, build with `npm run build -w @rdk-moss/agent`, then run the spec.mjs directly with `node`. +- Spec reference: `docs/superpowers/specs/2026-07-16-observability-instrumentation-design.md`. +- OTel version lines (from spec §7): experimental `^0.200` for `sdk-node`/`sdk-trace-base`/`exporter-trace-otlp-http`; metrics `sdk-metrics@^2.9` / `exporter-metrics-otlp-http@^0.220`; `api@^1.9` / `resources@^2.9` / `semantic-conventions@^1.43`. If peer conflicts arise, align experimental packages to `^0.220`. +- Span names use `moss.*` dot namespace. Errors go through `span.recordException(err)` + redacted `setStatus`. +- Disabling is noop-only: never add `if (enabled)` branches in business call-sites. +- Working repo is `D:\moss-drobotics` (git). All `git -C /d/moss-drobotics`. + +--- + +## File Structure + +**Create:** +- `packages/moss-agent/src/observability/sdk.ts` — NodeSDK assembly: Resource, OTLP trace exporter + BatchSpanProcessor, FileSpanProcessor, OTLP metric reader. `initObservability()` / `shutdownObservability()`. +- `packages/moss-agent/src/observability/metrics.ts` — `mossMetrics` handle: noop-by-default instruments, replaced with real ones by SDK. +- `packages/moss-agent/src/observability/file-trace.ts` — `FileSpanProcessor` implementing `SpanProcessor`, buffers `ReadableSpan`, flushes JSONL every 30s + on shutdown. +- `packages/moss-agent/src/observability/trace-context.ts` — `injectTraceHeaders(headers)` helper wrapping SDK `propagation.inject`. + +**Rewrite:** +- `packages/moss-agent/src/observability/tracing.ts` — replace custom `TraceRegistry`/`noopTracer` with SDK-backed `withSpan` + attribute builders. Keep `setTracer`/`setTraceRedactor` as noop shims (callers `cli-main.ts:65,188` and `moss-agent.ts:39` still import them) so existing imports don't break; they become no-ops because SDK owns tracing now. + +**Modify:** +- `packages/moss-agent/src/observability/index.ts` — re-export new API (`initObservability`, `shutdownObservability`, `mossMetrics`, `withSpan`, attribute builders, `injectTraceHeaders`); keep `redact`/`llm-usage` exports. +- `packages/moss-agent/src/cli-main.ts` — call `initObservability()` before `new MossAgent(...)` (line 611); call `shutdownObservability()` in the `finally` block. +- `packages/moss-agent/src/core/agent/moss-agent.ts` — wrap `chat()` (line 412) body in `moss.session` span; emit session metrics on completion. +- `packages/moss-agent/src/core/loop/agent-loop.ts` — wrap each turn iteration (around `executeLlmTurn`, line 426) in `moss.agent.turn` span. +- `packages/moss-agent/src/core/loop/agent-loop-llm-call.ts` — rename span `agent.llm_turn`→`moss.llm.request` (line 133); add `mossMetrics` calls. +- `packages/moss-agent/src/core/tools/execute-tool-call.ts` — wrap tool execution (line 242 `executeOneToolCall`) in `moss.tool.invoke` span; add `mossMetrics` calls. +- `packages/moss-agent/src/tools/web-fetch.ts` (line 541) + `web-search.ts` — use `injectTraceHeaders` on outbound fetch headers. +- `packages/moss-agent/package.json` — add 8 `@opentelemetry/*` deps. + +--- + +### Task 1: Add OpenTelemetry dependencies + +**Files:** +- Modify: `packages/moss-agent/package.json` + +**Interfaces:** +- Produces: installed `@opentelemetry/*` packages importable by later tasks. + +- [ ] **Step 1: Add dependencies to package.json** + +In `packages/moss-agent/package.json`, find the `"dependencies"` object and add these 8 entries (preserve existing entries; keep alphabetical if the file is sorted): + +```json +"@opentelemetry/api": "^1.9.0", +"@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", +"@opentelemetry/exporter-trace-otlp-http": "^0.200.0", +"@opentelemetry/resources": "^2.9.0", +"@opentelemetry/sdk-metrics": "^2.9.0", +"@opentelemetry/sdk-node": "^0.200.0", +"@opentelemetry/sdk-trace-base": "^0.200.0", +"@opentelemetry/semantic-conventions": "^1.43.0" +``` + +- [ ] **Step 2: Install** + +Run: `npm install -w @rdk-moss/agent` +Expected: install succeeds. If a peer-dependency conflict mentions `@opentelemetry/sdk-node` or `sdk-trace-base`, bump those two to `^0.220.0` and re-run. + +- [ ] **Step 3: Verify build still passes with no usage yet** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds (deps added, not yet imported — nothing breaks). + +- [ ] **Step 4: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/package.json package-lock.json +git -C /d/moss-drobotics commit -m "build(agent): add @opentelemetry/* dependencies for observability" +``` + +--- + +### Task 2: `metrics.ts` — noop-by-default instrument handle + +**Files:** +- Create: `packages/moss-agent/src/observability/metrics.ts` +- Test: `packages/moss-agent/test/observability-metrics.spec.mjs` + +**Interfaces:** +- Produces: `export const mossMetrics` with shape `{ llmTokens, llmDuration, toolInvocations, toolDuration, sessionCount, sessionDuration, sessionToolCount }`. Each counter has `.add(value, attrs?)`, each histogram has `.record(value, attrs?)`. Before SDK init these are noop; after init (Task 5) they are real. Later tasks call e.g. `mossMetrics.llmTokens.add(n, { direction: 'input', model })`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/moss-agent/test/observability-metrics.spec.mjs`: + +```javascript +#!/usr/bin/env node +// @rdk-moss/agent — mossMetrics noop-by-default contract +// Real instruments are wired by the SDK init (sdk.ts); without it, calls are silent. +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const distJs = path.join(dir, '..', 'dist', 'index.js'); +const mod = await import(pathToFileURL(distJs).href); +const { mossMetrics } = mod; + +assert.ok(mossMetrics, 'mossMetrics should be exported'); + +// noop-by-default: every call must be callable without throwing +assert.doesNotThrow(() => mossMetrics.llmTokens.add(10, { direction: 'input', model: 'm' })); +assert.doesNotThrow(() => mossMetrics.llmDuration.record(42, { model: 'm' })); +assert.doesNotThrow(() => mossMetrics.toolInvocations.add(1, { tool: 't', status: 'ok' })); +assert.doesNotThrow(() => mossMetrics.toolDuration.record(5, { tool: 't' })); +assert.doesNotThrow(() => mossMetrics.sessionCount.add(1, { outcome: 'done' })); +assert.doesNotThrow(() => mossMetrics.sessionDuration.record(100, { outcome: 'done' })); +assert.doesNotThrow(() => mossMetrics.sessionToolCount.record(3, { outcome: 'done' })); + +console.log('observability-metrics: OK'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node packages/moss-agent/test/observability-metrics.spec.mjs` +Expected: FAIL — `mossMetrics` is `undefined` (not yet exported). + +- [ ] **Step 3: Write minimal implementation** + +Create `packages/moss-agent/src/observability/metrics.ts`: + +```typescript +/** + * OpenTelemetry metrics handle for Moss. + * + * Instruments are noop until the SDK is initialized (sdk.ts sets the global + * meter provider). Business code calls .add()/.record() unconditionally — + * zero overhead when observability is disabled. + */ +import { metrics } from '@opentelemetry/api'; + +// getMeter() returns a noop meter until setGlobalMeterProvider is called, +// so these are noop instruments by default and real ones after SDK init. +const meter = metrics.getMeter('moss-agent'); + +export const mossMetrics = { + // LLM + llmTokens: meter.createCounter('moss.llm.tokens', { unit: '{token}' }), + llmDuration: meter.createHistogram('moss.llm.request.duration', { unit: 'ms' }), + // tool + toolInvocations: meter.createCounter('moss.tool.invocations'), + toolDuration: meter.createHistogram('moss.tool.invoke.duration', { unit: 'ms' }), + // session + sessionCount: meter.createCounter('moss.session.count'), + sessionDuration: meter.createHistogram('moss.session.duration', { unit: 'ms' }), + sessionToolCount: meter.createHistogram('moss.session.tool_count'), +}; +``` + +- [ ] **Step 4: Export from index.ts** + +In `packages/moss-agent/src/observability/index.ts`, add at end: + +```typescript +export { mossMetrics } from './metrics.js'; +``` + +- [ ] **Step 5: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `node packages/moss-agent/test/observability-metrics.spec.mjs` +Expected: PASS, prints `observability-metrics: OK`. + +- [ ] **Step 7: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/observability/metrics.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-metrics.spec.mjs +git -C /d/moss-drobotics commit -m "feat(observability): add noop-by-default mossMetrics instrument handle" +``` + +--- + +### Task 3: `tracing.ts` — SDK-backed `withSpan` + attribute builders + +**Files:** +- Rewrite: `packages/moss-agent/src/observability/tracing.ts` +- Test: `packages/moss-agent/test/observability-tracing.spec.mjs` +- Modify: `packages/moss-agent/src/observability/index.ts` (re-export new builders) + +**Interfaces:** +- Consumes: `@opentelemetry/api` `trace`/`context`/`SpanStatusCode`; existing `redactSensitiveData` (from `./redact.js`); existing `errorMessage` (from `../errors.js`). +- Produces: `withSpan(name, attributes, fn)`, `sessionAttributes(runId, model, sessionKey)`, `turnAttributes(runId, turn, model)`, `llmAttributes(runId, model, inputTokens)`, `toolAttributes(runId, toolName, toolCallId)`. Also keeps `setTracer`/`setTraceRedactor` as noop exports (callers import them) and `TraceSpan`/`Tracer` types for compatibility. + +- [ ] **Step 1: Write the failing test** + +Create `packages/moss-agent/test/observability-tracing.spec.mjs`: + +```javascript +#!/usr/bin/env node +// @rdk-moss/agent — withSpan + attribute builders contract +// No SDK init here: withSpan must still run the fn and propagate its result/throw, +// because the tracer is noop until the SDK is started. +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const distJs = path.join(dir, '..', 'dist', 'index.js'); +const mod = await import(pathToFileURL(distJs).href); +const { withSpan, turnAttributes, toolAttributes, llmAttributes, sessionAttributes } = mod; + +// fn result propagates through noop span +const r = await withSpan('test.span', { a: 1 }, async () => 42); +assert.equal(r, 42, 'withSpan returns fn result'); + +// fn error propagates (noop span does not swallow) +await assert.rejects( + () => withSpan('test.span', undefined, async () => { throw new Error('boom'); }), + /boom/, + 'withSpan rethrows fn errors', +); + +// attribute builders return plain objects with the right keys +assert.deepEqual(turnAttributes('r1', 3, 'm'), { runId: 'r1', turn: 3, model: 'm' }); +assert.deepEqual(toolAttributes('r1', 'bash', 'c1'), { runId: 'r1', toolName: 'bash', toolCallId: 'c1' }); +assert.deepEqual(llmAttributes('r1', 'm', 100), { runId: 'r1', model: 'm', inputTokens: 100 }); +assert.deepEqual(sessionAttributes('r1', 'm', 's1'), { runId: 'r1', model: 'm', sessionKey: 's1' }); + +console.log('observability-tracing: OK'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node packages/moss-agent/test/observability-tracing.spec.mjs` +Expected: FAIL — `withSpan` exists but old signature is `withSpan(name, attrs, fn, parent)`; attribute builders `sessionAttributes`/`llmAttributes` don't exist yet. (The old `withSpan` may even pass the result test but `sessionAttributes` is undefined → assert fails.) + +- [ ] **Step 3: Rewrite tracing.ts** + +Replace the entire contents of `packages/moss-agent/src/observability/tracing.ts` with: + +```typescript +/** + * OpenTelemetry-backed tracing for Moss. + * + * withSpan wraps an async fn in an SDK span. Before the SDK is initialized + * (sdk.ts), the global tracer is noop — withSpan still runs fn and propagates + * its result/throw, with zero tracing overhead. On error the exception is + * recorded on the span and the redacted message set on status. + */ +import { trace, context, SpanStatusCode, type Span } from '@opentelemetry/api'; +import { errorMessage } from '../errors.js'; +import { redactSensitiveData } from './redact.js'; + +const tracer = trace.getTracer('moss-agent'); + +/** Compatibility shim — SDK owns the tracer now; this is a no-op kept so + * cli-main.ts (setTracer('console')) and moss-agent.ts imports don't break. */ +export function setTracer(_tracer: unknown): void { + /* no-op: tracing is managed by the OTel SDK */ +} + +/** Compatibility shim — redaction still applies via redactSensitiveData in withSpan. */ +export function setTraceRedactor(_fn: (text: string) => string): void { + /* no-op */ +} + +// Preserved type exports for any existing consumer typings. +export interface TraceSpan { + setAttribute(key: string, value: string | number | boolean): void; + addEvent(name: string, attributes?: Record): void; + setStatus(ok: boolean, message?: string): void; + end(): void; +} +export interface Tracer { + startSpan( + name: string, + attributes?: Record, + parent?: TraceSpan, + ): TraceSpan; +} + +export async function withSpan( + name: string, + attributes: Record | undefined, + fn: (span: Span) => Promise, +): Promise { + const span = tracer.startSpan(name, attributes ? { attributes } : undefined); + return context.with(trace.setSpan(context.active(), span), async () => { + try { + const result = await fn(span); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (err) { + span.recordException(err); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: redactSensitiveData(errorMessage(err)), + }); + throw err; + } finally { + span.end(); + } + }); +} + +// ── attribute builders ────────────────────────────────────────────── +export const sessionAttributes = ( + runId: string, + model: string, + sessionKey: string, +): Record => ({ runId, model, sessionKey }); + +export const turnAttributes = ( + runId: string, + turn: number, + model: string, +): Record => ({ runId, turn, model }); + +export const llmAttributes = ( + runId: string, + model: string, + inputTokens: number, +): Record => ({ runId, model, inputTokens }); + +export const toolAttributes = ( + runId: string, + toolName: string, + toolCallId: string, +): Record => ({ runId, toolName, toolCallId }); +``` + +- [ ] **Step 4: Update index.ts re-exports** + +In `packages/moss-agent/src/observability/index.ts`, replace the `tracing.js` re-export block (the one exporting `TraceRegistry, setTracer, getTracer, withSpan, turnAttributes, toolAttributes, llmRequestAttributes`) with: + +```typescript +export { + withSpan, + setTracer, + setTraceRedactor, + sessionAttributes, + turnAttributes, + llmAttributes, + toolAttributes, +} from './tracing.js'; +export type { Tracer, TraceSpan } from './tracing.js'; +``` + +(Keep the existing `redact` and `llm-usage` export blocks unchanged.) + +- [ ] **Step 5: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. Note: `getTracer` and `TraceRegistry` and `llmRequestAttributes` are removed from exports — if any non-observability source imported them, the build fails with "has no exported member". If so, update that import to use the new builder names; `llmRequestAttributes` callers should use `llmAttributes`. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `node packages/moss-agent/test/observability-tracing.spec.mjs` +Expected: PASS, prints `observability-tracing: OK`. + +- [ ] **Step 7: Run full existing test suite to catch regressions** + +Run: `npm test -w @rdk-moss/agent` +Expected: all specs pass (the removed `getTracer`/`TraceRegistry` exports were only used by `cli-main.ts` `setTracer('console')` which is now a noop shim, and `moss-agent.ts` `setTraceRedactor` which is also a noop shim — both still import fine). + +- [ ] **Step 8: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/observability/tracing.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-tracing.spec.mjs +git -C /d/moss-drobotics commit -m "feat(observability): rewrite tracing.ts on OTel SDK withSpan + attribute builders" +``` + +--- + +### Task 4: `file-trace.ts` — FileSpanProcessor (local JSONL trace) + +**Files:** +- Create: `packages/moss-agent/src/observability/file-trace.ts` +- Test: `packages/moss-agent/test/observability-file-trace.spec.mjs` + +**Interfaces:** +- Consumes: `@opentelemetry/sdk-trace-base` `SpanProcessor`, `ReadableSpan`. +- Produces: `export class FileSpanProcessor implements SpanProcessor` with constructor `(workspaceDir: string)`, methods `onStart`, `onEnd`, `forceFlush`, `shutdown`. Spans buffered and flushed to `{workspaceDir}/.moss/analytics/traces.jsonl`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/moss-agent/test/observability-file-trace.spec.mjs`: + +```javascript +#!/usr/bin/env node +// @rdk-moss/agent — FileSpanProcessor writes one JSONL line per ended span. +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const distJs = path.join(dir, '..', 'dist', 'index.js'); +const mod = await import(pathToFileURL(distJs).href); +const { FileSpanProcessor } = mod; + +// Build a minimal ReadableSpan-shaped object the processor will accept. +// The processor only reads .name, .attributes, .startTime, .endTime (hrTime), +// .status, .events. We feed numbers for hrTime (ms) for simplicity. +function fakeSpan(name, attrs) { + return { + name, + attributes: attrs, + startTime: [0, 0], + endTime: [0, 1_000_000], + status: { code: 1 }, + events: [], + spanContext: () => ({ traceId: '0'.repeat(32), spanId: '0'.repeat(16) }), + }; +} + +const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-fp-')); +const proc = new FileSpanProcessor(tmpDir); + +proc.onStart(); +proc.onEnd(fakeSpan('moss.tool.invoke', { toolName: 'bash' })); +proc.onEnd(fakeSpan('moss.llm.request', { model: 'm' })); +await proc.forceFlush(); + +const file = path.join(tmpDir, '.moss', 'analytics', 'traces.jsonl'); +const content = await fs.readFile(file, 'utf-8'); +const lines = content.split('\n').filter(Boolean); +assert.equal(lines.length, 2, 'two spans → two JSONL lines'); +const first = JSON.parse(lines[0]); +assert.equal(first.name, 'moss.tool.invoke', 'first line is the first span'); +assert.equal(first.attributes.toolName, 'bash', 'attributes serialized'); + +// shutdown flushes remaining + clears timer +proc.onEnd(fakeSpan('moss.session', {})); +await proc.shutdown(); +const content2 = await fs.readFile(file, 'utf-8'); +assert.equal(content2.split('\n').filter(Boolean).length, 3, 'shutdown flushed the 3rd span'); + +await fs.rm(tmpDir, { recursive: true, force: true }); +console.log('observability-file-trace: OK'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node packages/moss-agent/test/observability-file-trace.spec.mjs` +Expected: FAIL — `FileSpanProcessor` is `undefined` (not exported). + +- [ ] **Step 3: Write minimal implementation** + +Create `packages/moss-agent/src/observability/file-trace.ts`: + +```typescript +/** + * FileSpanProcessor — mirrors ended spans to a local JSONL file. + * + * Sits alongside the OTLP trace exporter (BatchSpanProcessor) on the same + * tracer: every ended span is both sent to the receiver and appended to + * {workspaceDir}/.moss/analytics/traces.jsonl. Buffered + flushed every 30s + * and on shutdown. Never throws — file I/O is best-effort. + */ +import type { SpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const FLUSH_INTERVAL_MS = 30_000; + +// hrTime is [seconds, nanoseconds]. Reduce to ms epoch-ish number for the file. +function hrToMs(hr: [number, number]): number { + return hr[0] * 1000 + hr[1] / 1_000_000; +} + +function serializeSpan(span: ReadableSpan): string { + return JSON.stringify({ + name: span.name, + startTime: hrToMs(span.startTime as [number, number]), + endTime: hrToMs(span.endTime as [number, number]), + attributes: span.attributes ?? {}, + events: (span.events ?? []).map((e) => ({ + name: e.name, + time: hrToMs(e.time as [number, number]), + attrs: e.attributes ?? {}, + })), + status: span.status?.code === 2 ? 'error' : 'ok', + ...(span.status?.message ? { statusMessage: span.status.message } : {}), + }); +} + +export class FileSpanProcessor implements SpanProcessor { + private buffer: ReadableSpan[] = []; + private timer: ReturnType | null = null; + private readonly file: string; + + constructor(workspaceDir: string) { + this.file = path.join(workspaceDir, '.moss', 'analytics', 'traces.jsonl'); + this.timer = setInterval(() => { + void this.flush(); + }, FLUSH_INTERVAL_MS); + } + + onStart(): void { + /* nothing — we serialize on end */ + } + + onEnd(span: ReadableSpan): void { + this.buffer.push(span); + } + + async forceFlush(): Promise { + if (this.buffer.length === 0) return; + const snapshot = this.buffer.splice(0); + const lines = snapshot.map(serializeSpan).join('\n') + '\n'; + try { + await fs.mkdir(path.dirname(this.file), { recursive: true }); + await fs.appendFile(this.file, lines, 'utf-8'); + } catch { + /* never block the agent */ + } + } + + async shutdown(): Promise { + if (this.timer) clearInterval(this.timer); + this.timer = null; + await this.forceFlush(); + } +} +``` + +- [ ] **Step 4: Export from index.ts** + +In `packages/moss-agent/src/observability/index.ts`, add: + +```typescript +export { FileSpanProcessor } from './file-trace.js'; +``` + +- [ ] **Step 5: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `node packages/moss-agent/test/observability-file-trace.spec.mjs` +Expected: PASS, prints `observability-file-trace: OK`. + +- [ ] **Step 7: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/observability/file-trace.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-file-trace.spec.mjs +git -C /d/moss-drobotics commit -m "feat(observability): add FileSpanProcessor for local JSONL trace export" +``` + +--- + +### Task 5: `sdk.ts` — NodeSDK assembly + init/shutdown + +**Files:** +- Create: `packages/moss-agent/src/observability/sdk.ts` +- Modify: `packages/moss-agent/src/observability/index.ts` +- Test: `packages/moss-agent/test/observability-sdk.spec.mjs` + +**Interfaces:** +- Consumes: `@opentelemetry/sdk-node` `NodeSDK`; `exporter-trace-otlp-http` `OTLPTraceExporter`; `sdk-trace-base` `BatchSpanProcessor`; `sdk-metrics` `PeriodicExportingMetricReader`; `exporter-metrics-otlp-http` `OTLPMetricExporter`; `resources` `resourceFromAttributes`; `semantic-conventions` semconv; `FileSpanProcessor` (Task 4); reads package version. +- Produces: `initObservability(opts: { workspaceDir; serviceName?; otlpUrl? }): void` and `shutdownObservability(): Promise`. When env disables it, `initObservability` is a no-op. + +- [ ] **Step 1: Write the failing test** + +Create `packages/moss-agent/test/observability-sdk.spec.mjs`: + +```javascript +#!/usr/bin/env node +// @rdk-moss/agent — initObservability disabled path + idempotent shutdown. +// We do NOT start a real receiver here; we only assert the disabled path is +// a no-op and shutdown never throws. +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const distJs = path.join(dir, '..', 'dist', 'index.js'); +const mod = await import(pathToFileURL(distJs).href); +const { initObservability, shutdownObservability } = mod; + +const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-sdk-')); + +// Disabled: no MOSS_OTEL_ENABLED, no MOSS_OTEL_URL → must be a no-op, +// and must NOT create the traces.jsonl file (no SDK started). +delete process.env.MOSS_OTEL_ENABLED; +delete process.env.MOSS_OTEL_URL; +assert.doesNotThrow(() => initObservability({ workspaceDir: tmpDir })); +await shutdownObservability(); // idempotent, no-op when nothing started +const traceFile = path.join(tmpDir, '.moss', 'analytics', 'traces.jsonl'); +assert.ok(!await fs.access(traceFile).then(() => true).catch(() => false), + 'disabled path must not start file tracing'); + +await fs.rm(tmpDir, { recursive: true, force: true }); +console.log('observability-sdk: OK'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node packages/moss-agent/test/observability-sdk.spec.mjs` +Expected: FAIL — `initObservability`/`shutdownObservability` undefined. + +- [ ] **Step 3: Write minimal implementation** + +Create `packages/moss-agent/src/observability/sdk.ts`: + +```typescript +/** + * OTel SDK assembly: one NodeSDK shared by tracing and metrics. + * + * initObservability reads env to decide what to start. When disabled it does + * nothing (no SDK, no timers) — business code's noop instruments/spans carry + * zero cost. shutdownObservability flushes all exporters/processors. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { BatchSpanProcessor, type SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { semconv } from '@opentelemetry/semantic-conventions'; +import { FileSpanProcessor } from './file-trace.js'; + +export interface InitOptions { + workspaceDir: string; + serviceName?: string; + otlpUrl?: string; +} + +let sdk: NodeSDK | null = null; + +function readPackageVersion(): string { + try { + // dist/observability/sdk.js → package.json is two levels up (dist → package root) + const here = path.dirname(fileURLToPath(import.meta.url)); + const pkgPath = path.join(here, '..', 'package.json'); + return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version ?? '0'; + } catch { + return '0'; + } +} + +export function initObservability(opts: InitOptions): void { + const enabled = process.env.MOSS_OTEL_ENABLED === '1' || !!process.env.MOSS_OTEL_URL; + if (!enabled || sdk) return; + + const otlpUrl = process.env.MOSS_OTEL_URL ?? opts.otlpUrl ?? 'http://localhost:4318'; + const serviceName = process.env.MOSS_OTEL_SERVICE_NAME ?? opts.serviceName ?? 'moss'; + const metricsEnabled = process.env.MOSS_METRICS_ENABLED !== '0'; + const fileTraceEnabled = process.env.MOSS_FILE_TRACE !== '0'; + + const resource = resourceFromAttributes({ + [semconv.ATTR_SERVICE_NAME]: serviceName, + [semconv.ATTR_SERVICE_VERSION]: readPackageVersion(), + }); + + const spanProcessors: SpanProcessor[] = [ + new BatchSpanProcessor(new OTLPTraceExporter({ url: `${otlpUrl}/v1/traces` })), + ]; + if (fileTraceEnabled) { + spanProcessors.push(new FileSpanProcessor(opts.workspaceDir)); + } + + const metricReader = metricsEnabled + ? new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${otlpUrl}/v1/metrics` }), + exportIntervalMillis: 10_000, + }) + : undefined; + + sdk = new NodeSDK({ + resource, + spanProcessors, + ...(metricReader ? { metricReader } : {}), + }); + sdk.start(); +} + +export async function shutdownObservability(): Promise { + if (!sdk) return; + try { + await sdk.shutdown(); + } catch { + /* best-effort */ + } + sdk = null; +} +``` + +- [ ] **Step 4: Export from index.ts** + +In `packages/moss-agent/src/observability/index.ts`, add: + +```typescript +export { initObservability, shutdownObservability } from './sdk.js'; +export type { InitOptions } from './sdk.js'; +``` + +- [ ] **Step 5: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `node packages/moss-agent/test/observability-sdk.spec.mjs` +Expected: PASS, prints `observability-sdk: OK`. + +- [ ] **Step 7: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/observability/sdk.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-sdk.spec.mjs +git -C /d/moss-drobotics commit -m "feat(observability): add NodeSDK assembly with shared trace+metrics+file exporters" +``` + +--- + +### Task 6: `trace-context.ts` — `injectTraceHeaders` helper + +**Files:** +- Create: `packages/moss-agent/src/observability/trace-context.ts` +- Modify: `packages/moss-agent/src/observability/index.ts` +- Test: `packages/moss-agent/test/observability-trace-context.spec.mjs` + +**Interfaces:** +- Consumes: `@opentelemetry/api` `propagation`, `defaultTextMapSetter`. +- Produces: `injectTraceHeaders(headers: Record): Record` — injects W3C `traceparent` when inside a span, returns headers unchanged otherwise. + +- [ ] **Step 1: Write the failing test** + +Create `packages/moss-agent/test/observability-trace-context.spec.mjs`: + +```javascript +#!/usr/bin/env node +// @rdk-moss/agent — injectTraceHeaders is a passthrough when no span is active +// (graceful degradation), and injects traceparent when inside a withSpan. +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const distJs = path.join(dir, '..', 'dist', 'index.js'); +const mod = await import(pathToFileURL(distJs).href); +const { injectTraceHeaders, withSpan } = mod; + +// Outside any span: headers returned unchanged (no active context → nothing injected) +const plain = injectTraceHeaders({ accept: 'text/html' }); +assert.deepEqual(plain, { accept: 'text/html' }, 'no active span → passthrough'); +assert.ok(!plain.traceparent, 'no traceparent when no span active'); + +// Inside a withSpan: traceparent is injected (SDK noop tracer may not inject a +// real one, so we only assert the function doesn't throw and returns an object). +await withSpan('test', {}, async () => { + const h = injectTraceHeaders({ accept: 'text/html' }); + assert.equal(typeof h, 'object'); + assert.equal(h.accept, 'text/html', 'existing headers preserved'); +}); + +console.log('observability-trace-context: OK'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node packages/moss-agent/test/observability-trace-context.spec.mjs` +Expected: FAIL — `injectTraceHeaders` undefined. + +- [ ] **Step 3: Write minimal implementation** + +Create `packages/moss-agent/src/observability/trace-context.ts`: + +```typescript +/** + * Inject W3C traceparent for the active span into outbound request headers. + * Uses the SDK propagator, so it's consistent with span context propagation. + * No active span → headers returned unchanged (graceful degradation). + */ +import { propagation, defaultTextMapSetter } from '@opentelemetry/api'; + +export function injectTraceHeaders( + headers: Record, +): Record { + try { + propagation.inject(headers, defaultTextMapSetter); + } catch { + /* never break an outbound request over tracing */ + } + return headers; +} +``` + +- [ ] **Step 4: Export from index.ts** + +In `packages/moss-agent/src/observability/index.ts`, add: + +```typescript +export { injectTraceHeaders } from './trace-context.js'; +``` + +- [ ] **Step 5: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `node packages/moss-agent/test/observability-trace-context.spec.mjs` +Expected: PASS, prints `observability-trace-context: OK`. + +- [ ] **Step 7: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/observability/trace-context.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-trace-context.spec.mjs +git -C /d/moss-drobotics commit -m "feat(observability): add injectTraceHeaders for outbound W3C traceparent" +``` + +--- + +### Task 7: Wire `initObservability` + `shutdownObservability` into cli-main.ts + +**Files:** +- Modify: `packages/moss-agent/src/cli-main.ts` (imports ~line 65; init before line 611 `new MossAgent`; shutdown in the `finally` block ~line 1014) + +**Interfaces:** +- Consumes: `initObservability`, `shutdownObservability` from Task 5. + +- [ ] **Step 1: Add imports** + +In `packages/moss-agent/src/cli-main.ts`, find the existing import at line 65: +```typescript +import { setTracer } from './observability/tracing.js'; +``` +Replace it with: +```typescript +import { setTracer } from './observability/tracing.js'; +import { initObservability, shutdownObservability } from './observability/index.js'; +``` + +- [ ] **Step 2: Call init before agent construction** + +Find line 611 `const agent = new MossAgent({`. Immediately BEFORE it (after the `enableOtelMetrics`-equivalent region does not exist yet in drobotics; place it right before the `const agent = new MossAgent({` line), insert: + +```typescript + // Initialize observability (tracing + metrics + local file trace). + // No-op unless MOSS_OTEL_ENABLED=1 or MOSS_OTEL_URL is set. + initObservability({ workspaceDir: workspace }); + + const agent = new MossAgent({ +``` + +(Keep the existing `const agent = new MossAgent({` and everything after unchanged. The `workspace` variable is already in scope at this point — it was resolved earlier at line 465.) + +- [ ] **Step 3: Call shutdown in the finally block** + +Find the `} finally {` block at line 1014 containing `await closeMcpConnections(mcpConnections);`. Add the shutdown call so the block reads: + +```typescript + } finally { + await closeMcpConnections(mcpConnections); + await shutdownObservability(); + } +``` + +- [ ] **Step 4: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. + +- [ ] **Step 5: Run full test suite** + +Run: `npm test -w @rdk-moss/agent` +Expected: all specs pass (no behavioral change — init is no-op without env). + +- [ ] **Step 6: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/cli-main.ts +git -C /d/moss-drobotics commit -m "feat(cli): wire observability init/shutdown into main lifecycle" +``` + +--- + +### Task 8: `moss.session` span + session metrics in moss-agent.ts + +**Files:** +- Modify: `packages/moss-agent/src/core/agent/moss-agent.ts` (imports line 39; `chat()` at line 412) +- Test: `packages/moss-agent/test/observability-session-span.spec.mjs` + +**Interfaces:** +- Consumes: `withSpan`, `sessionAttributes`, `mossMetrics` from observability index. + +- [ ] **Step 1: Write the failing test** + +Create `packages/moss-agent/test/observability-session-span.spec.mjs`: + +```javascript +#!/usr/bin/env node +// @rdk-moss/agent — chat() wraps its work in a moss.session span and records +// session metrics on completion. Without the SDK started, withSpan is noop but +// still runs the wrapped fn, so chat() behavior is unchanged. We assert that +// the metrics handle is touched (noop add/record don't throw) and chat still +// returns a ChatResult shape. This is a smoke test, not a span-content test — +// real span content is verified end-to-end in the manual verification task. +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const distJs = path.join(dir, '..', 'dist', 'index.js'); +const mod = await import(pathToFileURL(distJs).href); +const { mossMetrics } = mod; + +// metrics handle is callable (noop until SDK init) +assert.doesNotThrow(() => { + mossMetrics.sessionCount.add(1, { outcome: 'agent_loop_done' }); + mossMetrics.sessionDuration.record(123, { outcome: 'agent_loop_done' }); + mossMetrics.sessionToolCount.record(2, { outcome: 'agent_loop_done' }); +}); + +console.log('observability-session-span: OK'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node packages/moss-agent/test/observability-session-span.spec.mjs` +Expected: it may already PASS (only checks metrics handle). That's fine — this task's real value is wiring the span, verified by build + suite. Keep the test as a regression guard for the metrics outcome labels. + +- [ ] **Step 3: Add imports to moss-agent.ts** + +In `packages/moss-agent/src/core/agent/moss-agent.ts`, the existing import at line 39 is: +```typescript +import { setTraceRedactor } from '../../observability/tracing.js'; +``` +Add a new import line near it (e.g., right after line 39): +```typescript +import { withSpan, sessionAttributes } from '../../observability/tracing.js'; +import { mossMetrics } from '../../observability/index.js'; +``` + +- [ ] **Step 4: Wrap chat() body in moss.session span** + +Read `packages/moss-agent/src/core/agent/moss-agent.ts` around line 412 (`async chat(...)`). The method body runs the agent loop and assembles `finalResult`. Wrap the body so the whole run is under one span and metrics are recorded at the end. Concretely, find the method's main execution (the call that produces `finalResult`, e.g. the `runAgentLoop`/loop invocation) and wrap it: + +```typescript + async chat(sessionKey: string, userMessage: string, options?: ChatOptions): Promise { + let finalResult: ChatResult | undefined; + const sessionStartMs = Date.now(); + const runId = options?.runId ?? `r-${Date.now()}`; + const model = String(this.config.model); + + finalResult = await withSpan( + 'moss.session', + sessionAttributes(runId, model, sessionKey), + async () => { + // ── existing chat() body that produces the result goes here ── + // (the original logic that sets finalResult / returns ChatResult) + return /* original return value or finalResult assembly */; + }, + ); + + // Record session metrics on completion. + const outcome = finalResult?.stopReason === 'max_turns_reached' ? 'max_turns' : 'agent_loop_done'; + mossMetrics.sessionCount.add(1, { outcome }); + mossMetrics.sessionDuration.record(Date.now() - sessionStartMs, { outcome }); + mossMetrics.sessionToolCount.record(finalResult?.toolCalls?.length ?? 0, { outcome }); + + return finalResult; + } +``` + +**IMPORTANT — this is a structural wrap, not a literal paste.** The implementer must: +1. Read the full current `chat()` method (lines ~412–550) first. +2. Identify the single expression/statement that yields the `ChatResult` (the existing return path or `finalResult` assignment). +3. Move that body inside the `withSpan` callback, returning its value from the callback. +4. Keep `runId`/`model` extraction consistent with whatever the method already uses (if `runId` is already computed in-scope, reuse it; do not duplicate). If the method already has a `runId` variable, use that name instead of redeclaring. +5. Preserve the existing return type and all side effects (event pushes, persistence). + +If wrapping the whole body is too invasive given the method's structure, the acceptable alternative is to wrap only the top-level loop invocation (the `runAgentLoop` or equivalent call) — the session span then covers the actual agent work, which is the goal. Record metrics immediately after that call resolves. + +- [ ] **Step 5: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. If `runId`/`model` naming collides with existing locals, rename the new locals to avoid shadowing. + +- [ ] **Step 6: Run full test suite** + +Run: `npm test -w @rdk-moss/agent` +Expected: all specs pass. If a behavior test fails, the wrap altered a side effect — fix by moving side-effectful statements outside the span callback (they should run, just not under the span) or by ensuring the callback returns the value the method returns. + +- [ ] **Step 7: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/core/agent/moss-agent.ts packages/moss-agent/test/observability-session-span.spec.mjs +git -C /d/moss-drobotics commit -m "feat(agent): wrap chat() in moss.session span + record session metrics" +``` + +--- + +### Task 9: `moss.agent.turn` span in agent-loop.ts + +**Files:** +- Modify: `packages/moss-agent/src/core/loop/agent-loop.ts` (imports; turn iteration around line 426 `executeLlmTurn`) + +**Interfaces:** +- Consumes: `withSpan`, `turnAttributes` from observability tracing. + +- [ ] **Step 1: Add imports** + +In `packages/moss-agent/src/core/loop/agent-loop.ts`, add near the top imports: +```typescript +import { withSpan, turnAttributes } from '../../observability/tracing.js'; +``` + +- [ ] **Step 2: Wrap each turn iteration in a turn span** + +Find the turn loop body. From exploration: line 330 `outerLoop: while (true)`, line 356 `state.turns++`, then around line 426 `const llmResult = await executeLlmTurn({...})` followed by `processLlmResponse` (line 460) and the control-flow checks. Wrap the per-turn work (from after `state.turns++` through the `continue`/`break` decision) in a span: + +Locate the block starting after `state.turns++;` (line 356) and the `stream.push({ type: 'turn_start', ... })`. Wrap the turn body: + +```typescript + state.turns++; + stream.push({ type: 'turn_start', turn: state.turns }); + + await withSpan( + 'moss.agent.turn', + turnAttributes(runId, state.turns, String(modelDef.id)), + async () => { + // ── existing per-turn body: ctxResult, executeLlmTurn, processLlmResponse, + // control-flow checks (continue/break) ── + // Keep all existing statements here verbatim. + }, + ); +``` + +**Implementation note:** The turn body contains `continue`/`break` statements that target the outer `outerLoop`/inner `while` loops. Moving them inside an async callback changes control flow — `continue`/`break` cannot appear inside an arrow function. Therefore: +- The span callback should contain ONLY the work that produces the turn's outcome (the LLM call + response processing + tool dispatch) and RETURN a control signal (`'continue' | 'break' | 'retry'`) rather than using `continue`/`break`. +- After the `withSpan` call, act on the returned signal with a real `continue`/`break` in the loop. + +Concretely, restructure so the callback returns the existing `control` value and the loop body maps it: + +```typescript + const turnControl = await withSpan( + 'moss.agent.turn', + turnAttributes(runId, state.turns, String(modelDef.id)), + async () => { + // existing body up to where it decides control, returning: + // 'continue' (was: continue;) + // 'break' (was: break;) + // 'retry' (was: state.turns--; continue;) + return control; // the value the body already computes + }, + ); + if (turnControl === 'break') break; + if (turnControl === 'retry') { state.turns--; continue; } + continue; +``` + +If the existing body already uses a `control`-returning helper pattern (it does — `executeLlmTurn` returns `LoopControlSignal`, `processLlmResponse` returns a control), this is mostly hoisting those returns out of the loop body into the callback and switching on the result. Read lines 330–520 carefully before editing. + +- [ ] **Step 3: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. Watch for "continue/break not allowed in arrow function" TS errors — those mean a `continue`/`break` was left inside the callback; convert it to a returned signal as above. + +- [ ] **Step 4: Run full test suite** + +Run: `npm test -w @rdk-moss/agent` +Expected: all specs pass. If a loop-control test fails, a signal was mis-mapped (e.g. `retry` handled as `continue`); fix the mapping to match the original semantics exactly. + +- [ ] **Step 5: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/core/loop/agent-loop.ts +git -C /d/moss-drobotics commit -m "feat(loop): wrap each agent-loop turn in moss.agent.turn span" +``` + +--- + +### Task 10: Rename LLM span + add LLM metrics in agent-loop-llm-call.ts + +**Files:** +- Modify: `packages/moss-agent/src/core/loop/agent-loop-llm-call.ts` (import line 22; span line 133; metrics after usage at ~line 177) + +**Interfaces:** +- Consumes: `mossMetrics` from observability index; existing `withSpan`/`turnAttributes`. + +- [ ] **Step 1: Add metrics import** + +At line 22 the file imports: +```typescript +import { withSpan, turnAttributes } from '../../observability/tracing.js'; +``` +Add after it: +```typescript +import { mossMetrics } from '../../observability/index.js'; +``` + +- [ ] **Step 2: Rename the span** + +At line 133, change: +```typescript + const llmTurn = await withSpan( + 'agent.llm_turn', + turnAttributes(runId, state.turns, String(modelDef.id)), +``` +to: +```typescript + const llmTurn = await withSpan( + 'moss.llm.request', + turnAttributes(runId, state.turns, String(modelDef.id)), +``` + +- [ ] **Step 3: Add LLM metrics on success** + +Find the block around line 177 that runs when `llmTurn.usage` is present (after `await recordLlmUsage({...success: true...})`). Add metrics after that call: + +```typescript + // Metrics (noop when metrics disabled) + const _llmModel = String(modelDef.id); + const _llmDuration = Date.now() - llmTurnStartedAt; + mossMetrics.llmTokens.add(llmTurn.usage.inputTokens, { direction: 'input', model: _llmModel }); + mossMetrics.llmTokens.add(llmTurn.usage.outputTokens, { direction: 'output', model: _llmModel }); + mossMetrics.llmDuration.record(_llmDuration, { model: _llmModel }); +``` + +- [ ] **Step 4: Add LLM metrics on failure** + +In the `catch (llmError)` block (around line 213), after `await recordLlmUsage({...success: false...})`, add: + +```typescript + // Metrics: record failed LLM call + mossMetrics.llmDuration.record(Date.now() - llmTurnStartedAt, { model: String(modelDef.id), status: 'error' }); +``` + +- [ ] **Step 5: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. + +- [ ] **Step 6: Run full test suite** + +Run: `npm test -w @rdk-moss/agent` +Expected: all specs pass. + +- [ ] **Step 7: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/core/loop/agent-loop-llm-call.ts +git -C /d/moss-drobotics commit -m "feat(loop): rename LLM span to moss.llm.request + record LLM token/duration metrics" +``` + +--- + +### Task 11: `moss.tool.invoke` span + tool metrics in execute-tool-call.ts + +**Files:** +- Modify: `packages/moss-agent/src/core/tools/execute-tool-call.ts` (imports; `executeOneToolCall` at line 242; outcome return at ~line 543) + +**Interfaces:** +- Consumes: `withSpan`, `toolAttributes`, `mossMetrics` from observability. + +- [ ] **Step 1: Add imports** + +At the top of `packages/moss-agent/src/core/tools/execute-tool-call.ts` (after line 31), add: +```typescript +import { withSpan, toolAttributes } from '../../observability/tracing.js'; +import { mossMetrics } from '../../observability/index.js'; +``` + +- [ ] **Step 2: Wrap tool execution in a span and record metrics** + +`executeOneToolCall` (line 242) currently computes `startMs` (line 341) and returns the outcome object at ~line 543 (`return { kind: 'completed', text, isError: errFlag, durationMs: Date.now() - startMs, ... }`). Wrap the core execution so each tool call is one span and metrics are recorded on completion. + +Find the `return { kind: 'completed', ... }` at ~line 543. Restructure the function body: wrap the execution (the part between `const startMs = Date.now();` at line 341 and the final `return { kind: 'completed', ...}`) in `withSpan`. Because the function has multiple return paths (`pre-blocked`, `completed`, and the `catch` returning `pre-blocked`), wrap only the main `completed` path: + +```typescript + // inside executeOneToolCall, replacing the final success return: + const _toolDuration = Date.now() - startMs; + mossMetrics.toolInvocations.add(1, { tool: call.name, status: errFlag ? 'error' : 'ok' }); + mossMetrics.toolDuration.record(_toolDuration, { tool: call.name }); + + return { + kind: 'completed', + text, + isError: errFlag, + durationMs: _toolDuration, + ...(aborted ? { aborted } : {}), + ...(structuredBlocks ? { structuredContent: structuredBlocks } : {}), + }; +``` + +For the span: the cleanest non-invasive placement is to wrap the call's main work. Read the function first. If wrapping the whole body in `withSpan` would force unwinding multiple early returns, instead add an inner `withSpan('moss.tool.invoke', toolAttributes(deps.sessionKey, call.name, call.id), async () => { ... })` around the `emitStart()` → hook → execute sequence (lines ~520–543) and have the callback return the outcome; the outer function returns what the callback returns. Use the `runId` available in scope if present, else pass `deps.sessionKey` as the `runId` attribute slot (the builder just needs an id; `sessionKey` is acceptable as it's the closest stable identifier here — note this in the span attribute by leaving the field as-is). + +- [ ] **Step 3: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. If `withSpan`'s async callback conflicts with the function's synchronous `return` paths, ensure every path inside the callback returns the outcome object. + +- [ ] **Step 4: Run full test suite** + +Run: `npm test -w @rdk-moss/agent` +Expected: all specs pass. Tool execution tests must still pass — the span wraps the work but returns the same outcome. + +- [ ] **Step 5: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/core/tools/execute-tool-call.ts +git -C /d/moss-drobotics commit -m "feat(tools): wrap tool execution in moss.tool.invoke span + record tool metrics" +``` + +--- + +### Task 12: Inject traceparent into web-fetch + web-search + +**Files:** +- Modify: `packages/moss-agent/src/tools/web-fetch.ts` (headers at line 541) +- Modify: `packages/moss-agent/src/tools/web-search.ts` (headers at lines 272, 370, 477, 591, 675, 735, 798) + +**Interfaces:** +- Consumes: `injectTraceHeaders` from observability index (Task 6). + +- [ ] **Step 1: Add import to web-fetch.ts** + +In `packages/moss-agent/src/tools/web-fetch.ts`, after the existing imports (after line 22), add: +```typescript +import { injectTraceHeaders } from '../observability/index.js'; +``` + +- [ ] **Step 2: Inject into web-fetch headers** + +At line 541 there is a `headers: { ... }` object passed to `fetch`. Wrap it: +```typescript + headers: injectTraceHeaders({ + /* existing header keys */ + }), +``` +(Keep all existing header keys inside the object passed to `injectTraceHeaders`; it returns the same object with `traceparent` added when a span is active.) + +- [ ] **Step 3: Add import to web-search.ts** + +In `packages/moss-agent/src/tools/web-search.ts`, after line 35, add: +```typescript +import { injectTraceHeaders } from '../observability/index.js'; +``` + +- [ ] **Step 4: Inject into web-search headers** + +For each `headers: { ... }` in web-search.ts (lines 272, 370, 477, 591, 675, 735, 798), wrap the object literal with `injectTraceHeaders({ ... })`. Example for line 477: +```typescript + headers: injectTraceHeaders({ 'user-agent': opts.userAgent, accept: 'text/html' }), +``` +Apply the same wrap to every `headers:` literal in the file. Skip any `headers` that are not object literals (e.g. a variable reference) — for those, wrap at the call site: `fetch(url, { ...init, headers: injectTraceHeaders(init.headers ?? {}) })`. + +- [ ] **Step 5: Build** + +Run: `npm run build -w @rdk-moss/agent` +Expected: build succeeds. + +- [ ] **Step 6: Run full test suite** + +Run: `npm test -w @rdk-moss/agent` +Expected: all specs pass. + +- [ ] **Step 7: Commit** + +```bash +git -C /d/moss-drobotics add packages/moss-agent/src/tools/web-fetch.ts packages/moss-agent/src/tools/web-search.ts +git -C /d/moss-drobotics commit -m "feat(tools): inject W3C traceparent into web-fetch/web-search outbound headers" +``` + +--- + +### Task 13: End-to-end manual verification + +**Files:** +- None modified — verification only. + +- [ ] **Step 1: Start the local OTLP receiver** + +Run: `D:\otel\start-receiver.cmd` (or `node D:\otel\otel-receiver.mjs` in a separate window). +Expected: receiver listens on `:4318` for `/v1/traces` and `/v1/metrics`; dashboard available at `http://localhost:3000`. + +- [ ] **Step 2: Run moss with observability enabled** + +Run (in a separate shell): +```bash +cd /d/moss-drobotics +MOSS_OTEL_ENABLED=1 MOSS_OTEL_SERVICE_NAME=moss node packages/moss-agent/dist/cli.js +``` +Expected: moss starts. Send one message that triggers a tool call (e.g. a prompt that makes the agent run a shell command or web_fetch). Exit with `/exit`. + +- [ ] **Step 3: Verify traces arrived at the receiver** + +Open `http://localhost:3000`. Expected: a trace rooted at `moss.session` containing nested `moss.agent.turn` → `moss.llm.request` and `moss.tool.invoke` spans, forming a tree. Span attributes include `runId`, `model`, `turn`, `toolName`, `inputTokens`, `outputTokens`. + +- [ ] **Step 4: Verify metrics arrived** + +In the dashboard, confirm metrics: `moss.llm.tokens` (input/output), `moss.llm.request.duration`, `moss.tool.invocations`, `moss.tool.invoke.duration`, `moss.session.count`, `moss.session.duration`, `moss.session.tool_count`. Each with `model`/`tool`/`outcome` dimensions. + +- [ ] **Step 5: Verify local JSONL trace file** + +Run: +```bash +wc -l /d/moss-drobotics/.moss/analytics/traces.jsonl +head -1 /d/moss-drobotics/.moss/analytics/traces.jsonl +``` +Expected: line count > 0; first line is valid JSON with `name`, `startTime`, `endTime`, `attributes`, `status`. + +- [ ] **Step 6: Verify disabled path is truly no-op** + +Run (no env): +```bash +cd /d/moss-drobotics +node packages/moss-agent/dist/cli.js +``` +Send one message, exit. Expected: no errors, no `traces.jsonl` created, no requests to `:4318`. + +- [ ] **Step 7: Verify fire-and-forget survives receiver down** + +Stop the receiver (Ctrl+C its window). Run moss with `MOSS_OTEL_ENABLED=1`, send a message. Expected: moss runs normally; span sends fail silently (no crash, no hang). Local `traces.jsonl` is still written (FileSpanProcessor doesn't need the receiver). + +- [ ] **Step 8: Run full automated suite one final time** + +Run: `npm test -w @rdk-moss/agent` +Expected: all specs pass. + +- [ ] **Step 9: Final commit (if any verification note files added)** + +If you recorded verification notes, commit them; otherwise no commit needed. The implementation is complete. + +```bash +# only if notes were added: +git -C /d/moss-drobotics add -A && git -C /d/moss-drobotics commit -m "docs(observability): verification notes" +``` From 6cf9d5f6ea41a5d662b6c61f3f4452bf6a518b41 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 16:08:20 +0800 Subject: [PATCH 03/21] docs: add observability instrumentation implementation plan 12-task TDD plan wiring OTel SDK (trace+metric+local file trace) into moss-agent: dependencies, metrics handles, FileSpanProcessor, SDK-backed withSpan rewrite, sdk/index entrypoint, web traceparent injection, CLI init, and the four instrumentation sites (llm/tool/turn/session spans + metrics), ending with an end-to-end integration spec. Co-Authored-By: Claude --- ...026-07-16-observability-instrumentation.md | 1788 +++++++++-------- 1 file changed, 934 insertions(+), 854 deletions(-) diff --git a/docs/superpowers/plans/2026-07-16-observability-instrumentation.md b/docs/superpowers/plans/2026-07-16-observability-instrumentation.md index 62bb206e..caef8ff9 100644 --- a/docs/superpowers/plans/2026-07-16-observability-instrumentation.md +++ b/docs/superpowers/plans/2026-07-16-observability-instrumentation.md @@ -1,60 +1,58 @@ -# Moss-Drobotics Observability (埋点) Implementation Plan +# Moss-Drobotics 可观测性(埋点)实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add OpenTelemetry tracing + metrics to `D:\moss-drobotics` so LLM calls, tool execution, and sessions emit spans/metrics to a local OTLP receiver and to a local JSONL trace file. +**Goal:** 为 `D:\moss-drobotics` 重新埋入 OpenTelemetry trace+metrics,纠正 from-remote fork 的 tracing/metrics 混搭债,并保留本地文件 trace。 -**Architecture:** Single `@opentelemetry/sdk-node` NodeSDK instance shared by tracing and metrics (one Resource, one SDK). A custom `FileSpanProcessor` mirrors spans to `.moss/analytics/traces.jsonl` alongside the OTLP trace exporter. Three span layers: `moss.session` → `moss.agent.turn` → `moss.llm.request` / `moss.tool.invoke`. Context propagation is SDK-native (no manual `parentSpan` threading). When disabled, all instruments are noop — zero overhead. +**Architecture:** tracing 与 metrics 同走官方 OTel SDK(`@opentelemetry/sdk-node`),共享一个 Resource;业务代码通过 `withSpan` / `mossMetrics.*` 无条件调用,SDK 在未启用时返回 noop(零开销);本地文件 trace 实现为 `FileSpanProcessor` 挂到 tracer,与 OTLP exporter 并存。 -**Tech Stack:** TypeScript ESM monorepo (`@rdk-moss/agent`), `@opentelemetry/*` (api 1.x + experimental 0.x + metrics 2.x), `node:assert/strict` `.spec.mjs` tests run against built `dist/`. +**Tech Stack:** TypeScript(ESM)、Node.js、OpenTelemetry JS SDK、moss-agent monorepo(包名 `@rdk-moss/agent`)、测试用 plain Node `*.spec.mjs` + `node:assert/strict`(经 `scripts/run-package-tests.mjs` 跑)。 ## Global Constraints -- Package: `@rdk-moss/agent`, source at `packages/moss-agent/src/`. ESM (`"type": "module"`), `.js` import specifiers in TS. -- Tests are `*.spec.mjs` files using `node:assert/strict`, executed against compiled `dist/` via `node packages/moss-agent/test/.spec.mjs` (or `npm test -w @rdk-moss/agent` which builds then runs `scripts/run-package-tests.mjs`). -- No vitest. Every test task must: write `test/.spec.mjs`, build with `npm run build -w @rdk-moss/agent`, then run the spec.mjs directly with `node`. -- Spec reference: `docs/superpowers/specs/2026-07-16-observability-instrumentation-design.md`. -- OTel version lines (from spec §7): experimental `^0.200` for `sdk-node`/`sdk-trace-base`/`exporter-trace-otlp-http`; metrics `sdk-metrics@^2.9` / `exporter-metrics-otlp-http@^0.220`; `api@^1.9` / `resources@^2.9` / `semantic-conventions@^1.43`. If peer conflicts arise, align experimental packages to `^0.220`. -- Span names use `moss.*` dot namespace. Errors go through `span.recordException(err)` + redacted `setStatus`. -- Disabling is noop-only: never add `if (enabled)` branches in business call-sites. -- Working repo is `D:\moss-drobotics` (git). All `git -C /d/moss-drobotics`. +- 工作目录:`D:\moss-drobotics`,仓库根。所有 git 命令用 `git -C /d/moss-drobotics` 或先 `cd /d/moss-drobotics`。 +- 包:所有代码改在 `packages/moss-agent/`,npm 包名 `@rdk-moss/agent`,workspace 命令形如 `npm run build -w @rdk-moss/agent`。 +- ESM:`"type": "module"`,import 路径必须带 `.js` 后缀(如 `'./tracing.js'`)。 +- 构建:`npm run build -w @rdk-moss/agent`(产 `dist/`);测试先 build 再 import `dist/`。 +- 测试:`npm run test -w @rdk-moss/agent`(内部 `npm run build && node ../../scripts/run-package-tests.mjs`,遍历 `packages/moss-agent/test/*.spec.mjs`)。spec 用 `node:assert/strict`,从 `dist/` import。 +- 关闭即零开销:未启用时 `withSpan` 走 noop、`mossMetrics.*` 是 noop instrument,业务代码不写 `if` 分支。 +- 不改变现有控制流语义:埋点只包 span / 记 metric,不吞错误、不改返回值。 +- 版本对齐:tracing experimental 包统一 `^0.200`;metrics 侧沿用 from-remote 的 `api@^1.9` / `sdk-metrics@^2.9` / `exporter-metrics-otlp-http@^0.220` / `resources@^2.9` / `semantic-conventions@^1.43`。若 install 时 peer 冲突,把 tracing experimental 包升到 `^0.220` 与 metrics exporter 对齐。 --- ## File Structure -**Create:** -- `packages/moss-agent/src/observability/sdk.ts` — NodeSDK assembly: Resource, OTLP trace exporter + BatchSpanProcessor, FileSpanProcessor, OTLP metric reader. `initObservability()` / `shutdownObservability()`. -- `packages/moss-agent/src/observability/metrics.ts` — `mossMetrics` handle: noop-by-default instruments, replaced with real ones by SDK. -- `packages/moss-agent/src/observability/file-trace.ts` — `FileSpanProcessor` implementing `SpanProcessor`, buffers `ReadableSpan`, flushes JSONL every 30s + on shutdown. -- `packages/moss-agent/src/observability/trace-context.ts` — `injectTraceHeaders(headers)` helper wrapping SDK `propagation.inject`. - -**Rewrite:** -- `packages/moss-agent/src/observability/tracing.ts` — replace custom `TraceRegistry`/`noopTracer` with SDK-backed `withSpan` + attribute builders. Keep `setTracer`/`setTraceRedactor` as noop shims (callers `cli-main.ts:65,188` and `moss-agent.ts:39` still import them) so existing imports don't break; they become no-ops because SDK owns tracing now. - -**Modify:** -- `packages/moss-agent/src/observability/index.ts` — re-export new API (`initObservability`, `shutdownObservability`, `mossMetrics`, `withSpan`, attribute builders, `injectTraceHeaders`); keep `redact`/`llm-usage` exports. -- `packages/moss-agent/src/cli-main.ts` — call `initObservability()` before `new MossAgent(...)` (line 611); call `shutdownObservability()` in the `finally` block. -- `packages/moss-agent/src/core/agent/moss-agent.ts` — wrap `chat()` (line 412) body in `moss.session` span; emit session metrics on completion. -- `packages/moss-agent/src/core/loop/agent-loop.ts` — wrap each turn iteration (around `executeLlmTurn`, line 426) in `moss.agent.turn` span. -- `packages/moss-agent/src/core/loop/agent-loop-llm-call.ts` — rename span `agent.llm_turn`→`moss.llm.request` (line 133); add `mossMetrics` calls. -- `packages/moss-agent/src/core/tools/execute-tool-call.ts` — wrap tool execution (line 242 `executeOneToolCall`) in `moss.tool.invoke` span; add `mossMetrics` calls. -- `packages/moss-agent/src/tools/web-fetch.ts` (line 541) + `web-search.ts` — use `injectTraceHeaders` on outbound fetch headers. -- `packages/moss-agent/package.json` — add 8 `@opentelemetry/*` deps. +新建 / 修改(均在 `packages/moss-agent/`): + +- **Create** `src/observability/metrics.ts` — `mossMetrics` 仪器句柄(counter/histogram)。 +- **Create** `src/observability/file-trace.ts` — `FileSpanProcessor`:消费 SDK `ReadableSpan`,缓冲后 flush 到 `.moss/analytics/traces.jsonl`,含 `getStats` 聚合。 +- **Create** `src/observability/sdk.ts` — `initObservabilitySdk` / `shutdownObservabilitySdk`:装配 NodeSDK、Resource、spanProcessors、metricReader。 +- **Rewrite** `src/observability/tracing.ts` — 从旧手写 tracer 改为 SDK-backed `withSpan` + attributes 构造器;保留 `setTracer`/`getTracer`/`TraceRegistry` 等**导出壳**(noop)以免破坏 `cli-main.ts:65,188` 与 `moss-agent.ts`、`agent-loop-llm-call.ts:22` 的 import。 +- **Rewrite** `src/observability/index.ts` — 公共入口:`initObservability` / `shutdownObservability` + 重导出 withSpan/mossMetrics/attributes,保留 redact/llm-usage 导出。 +- **Modify** `packages/moss-agent/package.json` — 加 8 个 `@opentelemetry/*` 依赖。 +- **Modify** `src/cli-main.ts` — agent 创建前调 `initObservability`;退出调 `shutdownObservability`;保留 `setTracer('console')` 调用(noop shim)。 +- **Modify** `src/core/agent/moss-agent.ts` — `chat()` 开 `moss.session` 根 span + session metrics。 +- **Modify** `src/core/loop/agent-loop.ts` — 主循环每轮外包 `moss.agent.turn` span。 +- **Modify** `src/core/loop/agent-loop-llm-call.ts` — span 名改 `moss.llm.request`,加 llm metrics。 +- **Modify** `src/core/tools/execute-tool-call.ts` — `executeOneToolCall` 内包 `moss.tool.invoke` span + tool metrics。 +- **Modify** `src/tools/web-fetch.ts` + `src/tools/web-search.ts` — 出站 fetch headers 用 `propagation.inject` 注入 traceparent。 +- **Test** `test/observability-tracing.spec.mjs`、`test/observability-file-trace.spec.mjs`、`test/observability-metrics.spec.mjs`、`test/observability-noop.spec.mjs`、`test/observability-integration.spec.mjs`。 --- -### Task 1: Add OpenTelemetry dependencies +## Task 1: 加 OTel 依赖并安装 **Files:** -- Modify: `packages/moss-agent/package.json` +- Modify: `packages/moss-agent/package.json`(dependencies 块) **Interfaces:** -- Produces: installed `@opentelemetry/*` packages importable by later tasks. +- Consumes: 无 +- Produces: 可 import 的 `@opentelemetry/*` 模块(后续 task 依赖) -- [ ] **Step 1: Add dependencies to package.json** +- [ ] **Step 1: 在 `dependencies` 块加入 8 个包** -In `packages/moss-agent/package.json`, find the `"dependencies"` object and add these 8 entries (preserve existing entries; keep alphabetical if the file is sorted): +打开 `packages/moss-agent/package.json`,在 `"dependencies"` 对象内(按字母序插入)加入: ```json "@opentelemetry/api": "^1.9.0", @@ -64,89 +62,104 @@ In `packages/moss-agent/package.json`, find the `"dependencies"` object and add "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-node": "^0.200.0", "@opentelemetry/sdk-trace-base": "^0.200.0", -"@opentelemetry/semantic-conventions": "^1.43.0" +"@opentelemetry/semantic-conventions": "^1.43.0", ``` -- [ ] **Step 2: Install** +- [ ] **Step 2: 安装** -Run: `npm install -w @rdk-moss/agent` -Expected: install succeeds. If a peer-dependency conflict mentions `@opentelemetry/sdk-node` or `sdk-trace-base`, bump those two to `^0.220.0` and re-run. +Run: `cd /d/moss-drobotics && npm install -w @rdk-moss/agent` +Expected: 安装成功。若出现 `ERESOLVE` peer 冲突,把 4 个 tracing experimental 包(`sdk-node` / `sdk-trace-base` / `exporter-trace-otlp-http`,以及视情况 `exporter-metrics-otlp-http`)统一升到 `^0.220.0` 后重试。 -- [ ] **Step 3: Verify build still passes with no usage yet** +- [ ] **Step 3: 验证可 import 且 build 不破** -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds (deps added, not yet imported — nothing breaks). +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent` +Expected: build 成功(此步尚未引用新包,仅确认依赖到位、不破坏现有构建)。 -- [ ] **Step 4: Commit** +- [ ] **Step 4: 跑现有测试确认无回归** + +Run: `cd /d/moss-drobotics && npm run test -w @rdk-moss/agent` +Expected: 全部 spec 通过(应与改动前一致)。 + +- [ ] **Step 5: Commit** ```bash -git -C /d/moss-drobotics add packages/moss-agent/package.json package-lock.json -git -C /d/moss-drobotics commit -m "build(agent): add @opentelemetry/* dependencies for observability" +cd /d/moss-drobotics +git add packages/moss-agent/package.json package-lock.json +git commit -m "chore(agent): add @opentelemetry/* dependencies for instrumentation" ``` --- -### Task 2: `metrics.ts` — noop-by-default instrument handle +## Task 2: metrics.ts — mossMetrics 仪器句柄 **Files:** - Create: `packages/moss-agent/src/observability/metrics.ts` - Test: `packages/moss-agent/test/observability-metrics.spec.mjs` **Interfaces:** -- Produces: `export const mossMetrics` with shape `{ llmTokens, llmDuration, toolInvocations, toolDuration, sessionCount, sessionDuration, sessionToolCount }`. Each counter has `.add(value, attrs?)`, each histogram has `.record(value, attrs?)`. Before SDK init these are noop; after init (Task 5) they are real. Later tasks call e.g. `mossMetrics.llmTokens.add(n, { direction: 'input', model })`. +- Consumes: `@opentelemetry/api` 的 `metrics` +- Produces: `mossMetrics`(对象,含 counter/histogram;未注册 provider 时为 noop) -- [ ] **Step 1: Write the failing test** +- [ ] **Step 1: 写 spec(验证导出 + noop 行为)** Create `packages/moss-agent/test/observability-metrics.spec.mjs`: ```javascript #!/usr/bin/env node -// @rdk-moss/agent — mossMetrics noop-by-default contract -// Real instruments are wired by the SDK init (sdk.ts); without it, calls are silent. +// mossMetrics instruments export + noop behavior (no provider registered). import assert from 'node:assert/strict'; import { fileURLToPath, pathToFileURL } from 'node:url'; import path from 'node:path'; const dir = path.dirname(fileURLToPath(import.meta.url)); -const distJs = path.join(dir, '..', 'dist', 'index.js'); -const mod = await import(pathToFileURL(distJs).href); +const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'metrics.js')).href); const { mossMetrics } = mod; +// 未 setGlobalMeterProvider 时返回 noop meter,instruments 仍可调用不抛错。 assert.ok(mossMetrics, 'mossMetrics should be exported'); +assert.equal(typeof mossMetrics.llmTokens.add, 'function', 'llmTokens.add is a function'); +assert.equal(typeof mossMetrics.llmDuration.record, 'function', 'llmDuration.record is a function'); +assert.equal(typeof mossMetrics.toolInvocations.add, 'function', 'toolInvocations.add is a function'); +assert.equal(typeof mossMetrics.toolDuration.record, 'function', 'toolDuration.record is a function'); +assert.equal(typeof mossMetrics.sessionCount.add, 'function', 'sessionCount.add is a function'); +assert.equal(typeof mossMetrics.sessionDuration.record, 'function', 'sessionDuration.record is a function'); +assert.equal(typeof mossMetrics.sessionToolCount.record, 'function', 'sessionToolCount.record is a function'); + +// noop 调用零成本、不抛 +assert.doesNotThrow(() => { + mossMetrics.llmTokens.add(10, { direction: 'input', model: 'm' }); + mossMetrics.llmDuration.record(123, { model: 'm' }); + mossMetrics.toolInvocations.add(1, { tool: 't', status: 'ok' }); + mossMetrics.sessionCount.add(1, { outcome: 'ok' }); + mossMetrics.sessionToolCount.record(3, { outcome: 'ok' }); +}); -// noop-by-default: every call must be callable without throwing -assert.doesNotThrow(() => mossMetrics.llmTokens.add(10, { direction: 'input', model: 'm' })); -assert.doesNotThrow(() => mossMetrics.llmDuration.record(42, { model: 'm' })); -assert.doesNotThrow(() => mossMetrics.toolInvocations.add(1, { tool: 't', status: 'ok' })); -assert.doesNotThrow(() => mossMetrics.toolDuration.record(5, { tool: 't' })); -assert.doesNotThrow(() => mossMetrics.sessionCount.add(1, { outcome: 'done' })); -assert.doesNotThrow(() => mossMetrics.sessionDuration.record(100, { outcome: 'done' })); -assert.doesNotThrow(() => mossMetrics.sessionToolCount.record(3, { outcome: 'done' })); - -console.log('observability-metrics: OK'); +console.error('[spec] observability-metrics OK'); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: 运行确认失败(模块不存在)** -Run: `node packages/moss-agent/test/observability-metrics.spec.mjs` -Expected: FAIL — `mossMetrics` is `undefined` (not yet exported). +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent && node packages/moss-agent/test/observability-metrics.spec.mjs` +Expected: FAIL(`dist/observability/metrics.js` 不存在,import 报 `ERR_MODULE_NOT_FOUND`)。 -- [ ] **Step 3: Write minimal implementation** +- [ ] **Step 3: 实现 metrics.ts** Create `packages/moss-agent/src/observability/metrics.ts`: ```typescript /** - * OpenTelemetry metrics handle for Moss. + * OpenTelemetry metrics for Moss — instrument handles. + * + * Uses the global MeterProvider. When none is registered (observability + * disabled), metrics.getMeter() returns a noop meter whose instruments are + * no-ops, so business code calls .add()/.record() unconditionally at zero cost. * - * Instruments are noop until the SDK is initialized (sdk.ts sets the global - * meter provider). Business code calls .add()/.record() unconditionally — - * zero overhead when observability is disabled. + * Usage: + * import { mossMetrics } from './observability/index.js'; + * mossMetrics.llmTokens.add(inputTokens, { direction: 'input', model }); */ import { metrics } from '@opentelemetry/api'; -// getMeter() returns a noop meter until setGlobalMeterProvider is called, -// so these are noop instruments by default and real ones after SDK init. const meter = metrics.getMeter('moss-agent'); export const mossMetrics = { @@ -159,128 +172,351 @@ export const mossMetrics = { // session sessionCount: meter.createCounter('moss.session.count'), sessionDuration: meter.createHistogram('moss.session.duration', { unit: 'ms' }), + // 每轮工具数(纠正 from-remote 把它误命名为 session.turns 的错位) sessionToolCount: meter.createHistogram('moss.session.tool_count'), }; ``` -- [ ] **Step 4: Export from index.ts** +- [ ] **Step 4: build + 跑 spec 确认通过** -In `packages/moss-agent/src/observability/index.ts`, add at end: +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent && node packages/moss-agent/test/observability-metrics.spec.mjs` +Expected: PASS,输出 `[spec] observability-metrics OK`。 -```typescript -export { mossMetrics } from './metrics.js'; +- [ ] **Step 5: Commit** + +```bash +cd /d/moss-drobotics +git add packages/moss-agent/src/observability/metrics.ts packages/moss-agent/test/observability-metrics.spec.mjs +git commit -m "feat(agent): add mossMetrics OTel instrument handles" ``` -- [ ] **Step 5: Build** +--- + +## Task 3: file-trace.ts — FileSpanProcessor 本地落盘 -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. +**Files:** +- Create: `packages/moss-agent/src/observability/file-trace.ts` +- Test: `packages/moss-agent/test/observability-file-trace.spec.mjs` + +**Interfaces:** +- Consumes: `@opentelemetry/sdk-trace-base` 的 `SpanProcessor`、`ReadableSpan` +- Produces: `FileSpanProcessor`(class)、`serializeSpan`(函数,被 getStats 复用)、`readTraceStats`(从 jsonl 聚合) -- [ ] **Step 6: Run test to verify it passes** +- [ ] **Step 1: 写 spec(落盘 + 聚合)** -Run: `node packages/moss-agent/test/observability-metrics.spec.mjs` -Expected: PASS, prints `observability-metrics: OK`. +Create `packages/moss-agent/test/observability-file-trace.spec.mjs`: -- [ ] **Step 7: Commit** +```javascript +#!/usr/bin/env node +// FileSpanProcessor buffers spans, flushes to traces.jsonl, and readTraceStats aggregates. +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'file-trace.js')).href); +const { FileSpanProcessor, readTraceStats } = mod; + +// 构造一个最小 ReadableSpan 形状 +function fakeSpan(name, attrs, isError) { + const now = Date.now(); + return { + name, + kind: 0, + spanContext: () => ({ traceId: 't'.repeat(32), spanId: 's'.repeat(16) }), + startTime: [[now - 50, 0]], + endTime: [[now, 0]], + attributes: attrs ?? {}, + status: isError ? { code: 2, message: 'boom' } : { code: 1 }, + events: [], + resource: { attributes: [] }, + instrumentationScope: { name: 'moss-agent' }, + }; +} + +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-trace-')); +const proc = new FileSpanProcessor(tmp); +proc.onStart({}, fakeSpan('noop')); +proc.onEnd(fakeSpan('moss.tool.invoke', { toolName: 'read_file' }, false)); +proc.onEnd(fakeSpan('moss.tool.invoke', { toolName: 'read_file' }, true)); +proc.onEnd(fakeSpan('moss.llm.request', { model: 'm' }, false)); +await proc.forceFlush(); + +const file = path.join(tmp, '.moss', 'analytics', 'traces.jsonl'); +const content = await fs.readFile(file, 'utf8'); +const lines = content.split('\n').filter(Boolean); +assert.equal(lines.length, 3, 'should write 3 span lines'); + +const stats = await readTraceStats(file); +assert.equal(stats.totalSpans, 3, 'totalSpans'); +assert.equal(stats.totalErrors, 1, 'totalErrors'); +assert.ok(stats.byName['moss.tool.invoke'], 'tool span aggregated by name'); +assert.equal(stats.byName['moss.tool.invoke'].count, 2, '2 tool spans'); +assert.equal(stats.byName['moss.tool.invoke'].errors, 1, '1 tool error'); +assert.equal(stats.toolSpans[0].toolName, 'read_file', 'tool breakdown by toolName'); + +await proc.shutdown(); +await fs.rm(tmp, { recursive: true, force: true }); +console.error('[spec] observability-file-trace OK'); +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent && node packages/moss-agent/test/observability-file-trace.spec.mjs` +Expected: FAIL(`dist/observability/file-trace.js` 不存在)。 + +- [ ] **Step 3: 实现 file-trace.ts** + +Create `packages/moss-agent/src/observability/file-trace.ts`: + +```typescript +/** + * FileSpanProcessor — serializes SDK ReadableSpans to a local JSONL file. + * + * Attached to the TracerProvider alongside the OTLP exporter, so the same + * spans ship to the receiver AND land on disk. Best-effort: flush failures + * never block the agent. + * + * Path: {workspaceDir}/.moss/analytics/traces.jsonl + */ +import type { SpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { SpanStatusCode } from '@opentelemetry/api'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const FLUSH_INTERVAL_MS = 30_000; + +export interface SerializedSpan { + name: string; + startTime: number; + endTime: number; + attributes: Record; + events: Array<{ name: string; time: number; attrs?: Record }>; + status: 'ok' | 'error'; + statusMessage?: string; +} + +export interface TraceStats { + totalSpans: number; + totalErrors: number; + errorRate: number; + byName: Record; + toolSpans: Array<{ toolName: string; count: number; errors: number; avgDurationMs: number }>; +} + +/** Convert an SDK ReadableSpan into the JSONL-friendly SerializedSpan shape. */ +export function serializeSpan(span: ReadableSpan): SerializedSpan { + const attrs: Record = {}; + for (const [k, v] of Object.entries(span.attributes ?? {})) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') attrs[k] = v; + } + return { + name: span.name, + startTime: Number(span.startTime[0]), + endTime: Number(span.endTime[0]), + attributes: attrs, + events: (span.events ?? []).map((e) => ({ + name: e.name, + time: Number(e.time[0]), + ...(e.attributes ? { attrs: e.attributes as Record } : {}), + })), + status: span.status.code === SpanStatusCode.ERROR ? 'error' : 'ok', + ...(span.status.code === SpanStatusCode.ERROR && span.status.message + ? { statusMessage: span.status.message } + : {}), + }; +} + +export class FileSpanProcessor implements SpanProcessor { + private buffer: ReadableSpan[] = []; + private timer: ReturnType | null = null; + private readonly file: string; + + constructor(workspaceDir: string) { + this.file = path.join(workspaceDir, '.moss', 'analytics', 'traces.jsonl'); + this.timer = setInterval(() => { void this.flush(); }, FLUSH_INTERVAL_MS); + } + + onStart(_span: ReadableSpan, _parentContext: unknown): void {} + + onEnd(span: ReadableSpan): void { + this.buffer.push(span); + } + + async flush(): Promise { + if (this.buffer.length === 0) return; + const snapshot = this.buffer.splice(0); + const lines = snapshot.map(serializeSpan).map((s) => JSON.stringify(s)).join('\n') + '\n'; + try { + await fs.mkdir(path.dirname(this.file), { recursive: true }); + await fs.appendFile(this.file, lines, 'utf-8'); + } catch { + // Silently ignore — never block the agent. + } + } + + async forceFlush(): Promise { + await this.flush(); + } + + async shutdown(): Promise { + if (this.timer) clearInterval(this.timer); + this.timer = null; + await this.flush(); + } +} + +function emptyStats(): TraceStats { + return { totalSpans: 0, totalErrors: 0, errorRate: 0, byName: {}, toolSpans: [] }; +} + +/** Read aggregated stats from a traces.jsonl file (for CLI reporting). */ +export async function readTraceStats(file: string): Promise { + let lines: string[]; + try { + lines = (await fs.readFile(file, 'utf-8')).split('\n').filter(Boolean); + } catch { + return emptyStats(); + } + const stats = emptyStats(); + for (const line of lines) { + let span: SerializedSpan; + try { span = JSON.parse(line); } catch { continue; } + stats.totalSpans++; + if (span.status === 'error') stats.totalErrors++; + const entry = stats.byName[span.name] ??= { count: 0, errors: 0, avgDurationMs: 0 }; + entry.count++; + if (span.status === 'error') entry.errors++; + const duration = span.endTime - span.startTime; + entry.avgDurationMs = (entry.avgDurationMs * (entry.count - 1) + duration) / entry.count; + if (span.name === 'moss.tool.invoke') { + const toolName = String(span.attributes.toolName ?? 'unknown'); + let tool = stats.toolSpans.find((t) => t.toolName === toolName); + if (!tool) { tool = { toolName, count: 0, errors: 0, avgDurationMs: 0 }; stats.toolSpans.push(tool); } + tool.count++; + if (span.status === 'error') tool.errors++; + tool.avgDurationMs = (tool.avgDurationMs * (tool.count - 1) + duration) / tool.count; + } + } + stats.errorRate = stats.totalSpans > 0 ? stats.totalErrors / stats.totalSpans : 0; + stats.toolSpans.sort((a, b) => b.count - a.count); + return stats; +} +``` + +- [ ] **Step 4: build + 跑 spec 确认通过** + +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent && node packages/moss-agent/test/observability-file-trace.spec.mjs` +Expected: PASS,输出 `[spec] observability-file-trace OK`。 + +- [ ] **Step 5: Commit** ```bash -git -C /d/moss-drobotics add packages/moss-agent/src/observability/metrics.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-metrics.spec.mjs -git -C /d/moss-drobotics commit -m "feat(observability): add noop-by-default mossMetrics instrument handle" +cd /d/moss-drobotics +git add packages/moss-agent/src/observability/file-trace.ts packages/moss-agent/test/observability-file-trace.spec.mjs +git commit -m "feat(agent): add FileSpanProcessor for local JSONL trace export" ``` --- -### Task 3: `tracing.ts` — SDK-backed `withSpan` + attribute builders +## Task 4: 重写 tracing.ts(SDK-backed withSpan + 保留导出壳) **Files:** - Rewrite: `packages/moss-agent/src/observability/tracing.ts` - Test: `packages/moss-agent/test/observability-tracing.spec.mjs` -- Modify: `packages/moss-agent/src/observability/index.ts` (re-export new builders) **Interfaces:** -- Consumes: `@opentelemetry/api` `trace`/`context`/`SpanStatusCode`; existing `redactSensitiveData` (from `./redact.js`); existing `errorMessage` (from `../errors.js`). -- Produces: `withSpan(name, attributes, fn)`, `sessionAttributes(runId, model, sessionKey)`, `turnAttributes(runId, turn, model)`, `llmAttributes(runId, model, inputTokens)`, `toolAttributes(runId, toolName, toolCallId)`. Also keeps `setTracer`/`setTraceRedactor` as noop exports (callers import them) and `TraceSpan`/`Tracer` types for compatibility. +- Consumes: `@opentelemetry/api`(`trace`、`context`、`SpanStatusCode`) +- Produces: `withSpan(name, attrs, fn)`;attributes 构造器 `turnAttributes` / `toolAttributes` / `llmRequestAttributes` / `sessionAttributes`;**保留** `setTracer` / `getTracer` / `TraceRegistry` / `Tracer` / `TraceSpan` 导出(noop shim,供现有 import 不破)。 + +**注意:** 现有 `cli-main.ts:65` `import { setTracer } from './observability/tracing.js'` 与 `:188` `setTracer('console')`;`moss-agent.ts` 与 `agent-loop-llm-call.ts:22` 也从 tracing.js import。本 task 把 withSpan 改 SDK 实现,同时保留 `setTracer`/`TraceRegistry` 等为 noop shim,使现有 import 不需在本 task 内改动(cli-main 的真正初始化在 Task 7)。 -- [ ] **Step 1: Write the failing test** +- [ ] **Step 1: 写 spec(withSpan 成功/异常路径 + attributes 形状 + noop shim 存在)** Create `packages/moss-agent/test/observability-tracing.spec.mjs`: ```javascript #!/usr/bin/env node -// @rdk-moss/agent — withSpan + attribute builders contract -// No SDK init here: withSpan must still run the fn and propagate its result/throw, -// because the tracer is noop until the SDK is started. +// withSpan: success path sets OK, error path records exception + rethrows. +// attributes constructors produce expected shape. setTracer shim is a noop. import assert from 'node:assert/strict'; import { fileURLToPath, pathToFileURL } from 'node:url'; import path from 'node:path'; const dir = path.dirname(fileURLToPath(import.meta.url)); -const distJs = path.join(dir, '..', 'dist', 'index.js'); -const mod = await import(pathToFileURL(distJs).href); -const { withSpan, turnAttributes, toolAttributes, llmAttributes, sessionAttributes } = mod; - -// fn result propagates through noop span -const r = await withSpan('test.span', { a: 1 }, async () => 42); -assert.equal(r, 42, 'withSpan returns fn result'); +const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'tracing.js')).href); +const { withSpan, turnAttributes, toolAttributes, llmRequestAttributes, sessionAttributes, setTracer, getTracer, TraceRegistry } = mod; + +// 未注册 tracer provider 时为 noop tracer,withSpan 仍正常执行 fn 并返回结果。 +const result = await withSpan('test.span', { a: 1 }, async (span) => { + assert.equal(typeof span.setAttribute, 'function'); + assert.equal(typeof span.addEvent, 'function'); + assert.equal(typeof span.setStatus, 'function'); + assert.equal(typeof span.end, 'function'); + span.setAttribute('k', 'v'); + span.addEvent('ev', { x: 1 }); + return 42; +}); +assert.equal(result, 42, 'withSpan returns fn result'); -// fn error propagates (noop span does not swallow) +// 异常路径:rethrow(不吞错) await assert.rejects( - () => withSpan('test.span', undefined, async () => { throw new Error('boom'); }), + withSpan('test.err', {}, async () => { throw new Error('boom'); }), /boom/, - 'withSpan rethrows fn errors', ); -// attribute builders return plain objects with the right keys +// attributes 构造器形状 assert.deepEqual(turnAttributes('r1', 3, 'm'), { runId: 'r1', turn: 3, model: 'm' }); -assert.deepEqual(toolAttributes('r1', 'bash', 'c1'), { runId: 'r1', toolName: 'bash', toolCallId: 'c1' }); -assert.deepEqual(llmAttributes('r1', 'm', 100), { runId: 'r1', model: 'm', inputTokens: 100 }); -assert.deepEqual(sessionAttributes('r1', 'm', 's1'), { runId: 'r1', model: 'm', sessionKey: 's1' }); - -console.log('observability-tracing: OK'); +assert.deepEqual(toolAttributes('r1', 'read_file', 'tc1'), { runId: 'r1', toolName: 'read_file', toolCallId: 'tc1' }); +assert.deepEqual(llmRequestAttributes('r1', 'm', 100), { runId: 'r1', model: 'm', inputTokens: 100 }); +assert.deepEqual(sessionAttributes('r1', 'm', 'sk'), { runId: 'r1', model: 'm', sessionKey: 'sk' }); + +// 旧 API 保留为 noop shim,不抛 +assert.doesNotThrow(() => setTracer('console')); +assert.ok(getTracer()); +assert.ok(new TraceRegistry()); +console.error('[spec] observability-tracing OK'); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: 运行确认失败** -Run: `node packages/moss-agent/test/observability-tracing.spec.mjs` -Expected: FAIL — `withSpan` exists but old signature is `withSpan(name, attrs, fn, parent)`; attribute builders `sessionAttributes`/`llmAttributes` don't exist yet. (The old `withSpan` may even pass the result test but `sessionAttributes` is undefined → assert fails.) +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent && node packages/moss-agent/test/observability-tracing.spec.mjs` +Expected: FAIL(旧 tracing.ts 的 `withSpan` 第三参是 `(span: TraceSpan) => Promise` 但签名带 `parent?`,且 attributes 构造器缺 `sessionAttributes`;旧实现 `withSpan` 在异常时虽 rethrow 但不 `recordException`——本 spec 主要因缺 `sessionAttributes` 导出而失败)。 -- [ ] **Step 3: Rewrite tracing.ts** +- [ ] **Step 3: 重写 tracing.ts** -Replace the entire contents of `packages/moss-agent/src/observability/tracing.ts` with: +Replace **entire** `packages/moss-agent/src/observability/tracing.ts` with: ```typescript /** - * OpenTelemetry-backed tracing for Moss. + * Tracing — SDK-backed withSpan + attributes. * - * withSpan wraps an async fn in an SDK span. Before the SDK is initialized - * (sdk.ts), the global tracer is noop — withSpan still runs fn and propagates - * its result/throw, with zero tracing overhead. On error the exception is - * recorded on the span and the redacted message set on status. + * Uses the global TracerProvider. When none is registered (observability + * disabled), trace.getTracer() returns a noop tracer and withSpan runs fn + * directly with zero overhead. + * + * Legacy setTracer/TraceRegistry/Tracer/TraceSpan are kept as noop shims so + * existing imports (cli-main.ts, moss-agent.ts, agent-loop-llm-call.ts) do + * not break until callers are migrated to initObservability. */ -import { trace, context, SpanStatusCode, type Span } from '@opentelemetry/api'; +import { trace, context, SpanStatusCode } from '@opentelemetry/api'; +import type { Span } from '@opentelemetry/api'; import { errorMessage } from '../errors.js'; import { redactSensitiveData } from './redact.js'; const tracer = trace.getTracer('moss-agent'); -/** Compatibility shim — SDK owns the tracer now; this is a no-op kept so - * cli-main.ts (setTracer('console')) and moss-agent.ts imports don't break. */ -export function setTracer(_tracer: unknown): void { - /* no-op: tracing is managed by the OTel SDK */ -} - -/** Compatibility shim — redaction still applies via redactSensitiveData in withSpan. */ -export function setTraceRedactor(_fn: (text: string) => string): void { - /* no-op */ -} - -// Preserved type exports for any existing consumer typings. +/** Public span handle passed to withSpan's fn. */ export interface TraceSpan { setAttribute(key: string, value: string | number | boolean): void; addEvent(name: string, attributes?: Record): void; setStatus(ok: boolean, message?: string): void; end(): void; } + +/** Legacy Tracer interface — kept as noop shim. */ export interface Tracer { startSpan( name: string, @@ -289,19 +525,35 @@ export interface Tracer { ): TraceSpan; } +const noopSpan: TraceSpan = { + setAttribute() {}, + addEvent() {}, + setStatus() {}, + end() {}, +}; + +const noopTracer: Tracer = { + startSpan() { return noopSpan; }, +}; + +/** + * Run fn inside a span. On success sets OK; on throw records the exception + * (type/message/stack as a span event), sets ERROR with a redacted message, + * and rethrows. Never swallows errors. + */ export async function withSpan( name: string, attributes: Record | undefined, - fn: (span: Span) => Promise, + fn: (span: Span & TraceSpan) => Promise, ): Promise { - const span = tracer.startSpan(name, attributes ? { attributes } : undefined); + const span = tracer.startSpan(name, { attributes }) as Span & TraceSpan; return context.with(trace.setSpan(context.active(), span), async () => { try { const result = await fn(span); span.setStatus({ code: SpanStatusCode.OK }); return result; } catch (err) { - span.recordException(err); + span.recordException(err as Error); span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveData(errorMessage(err)), @@ -313,563 +565,416 @@ export async function withSpan( }); } -// ── attribute builders ────────────────────────────────────────────── -export const sessionAttributes = ( - runId: string, - model: string, - sessionKey: string, -): Record => ({ runId, model, sessionKey }); +// ── Attributes constructors (span dimensions, centralized) ────────────── -export const turnAttributes = ( +export function turnAttributes( runId: string, turn: number, model: string, -): Record => ({ runId, turn, model }); - -export const llmAttributes = ( - runId: string, - model: string, - inputTokens: number, -): Record => ({ runId, model, inputTokens }); +): Record { + return { runId, turn, model }; +} -export const toolAttributes = ( +export function toolAttributes( runId: string, toolName: string, toolCallId: string, -): Record => ({ runId, toolName, toolCallId }); -``` - -- [ ] **Step 4: Update index.ts re-exports** - -In `packages/moss-agent/src/observability/index.ts`, replace the `tracing.js` re-export block (the one exporting `TraceRegistry, setTracer, getTracer, withSpan, turnAttributes, toolAttributes, llmRequestAttributes`) with: - -```typescript -export { - withSpan, - setTracer, - setTraceRedactor, - sessionAttributes, - turnAttributes, - llmAttributes, - toolAttributes, -} from './tracing.js'; -export type { Tracer, TraceSpan } from './tracing.js'; -``` - -(Keep the existing `redact` and `llm-usage` export blocks unchanged.) - -- [ ] **Step 5: Build** - -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. Note: `getTracer` and `TraceRegistry` and `llmRequestAttributes` are removed from exports — if any non-observability source imported them, the build fails with "has no exported member". If so, update that import to use the new builder names; `llmRequestAttributes` callers should use `llmAttributes`. - -- [ ] **Step 6: Run test to verify it passes** - -Run: `node packages/moss-agent/test/observability-tracing.spec.mjs` -Expected: PASS, prints `observability-tracing: OK`. - -- [ ] **Step 7: Run full existing test suite to catch regressions** - -Run: `npm test -w @rdk-moss/agent` -Expected: all specs pass (the removed `getTracer`/`TraceRegistry` exports were only used by `cli-main.ts` `setTracer('console')` which is now a noop shim, and `moss-agent.ts` `setTraceRedactor` which is also a noop shim — both still import fine). - -- [ ] **Step 8: Commit** - -```bash -git -C /d/moss-drobotics add packages/moss-agent/src/observability/tracing.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-tracing.spec.mjs -git -C /d/moss-drobotics commit -m "feat(observability): rewrite tracing.ts on OTel SDK withSpan + attribute builders" -``` - ---- - -### Task 4: `file-trace.ts` — FileSpanProcessor (local JSONL trace) - -**Files:** -- Create: `packages/moss-agent/src/observability/file-trace.ts` -- Test: `packages/moss-agent/test/observability-file-trace.spec.mjs` - -**Interfaces:** -- Consumes: `@opentelemetry/sdk-trace-base` `SpanProcessor`, `ReadableSpan`. -- Produces: `export class FileSpanProcessor implements SpanProcessor` with constructor `(workspaceDir: string)`, methods `onStart`, `onEnd`, `forceFlush`, `shutdown`. Spans buffered and flushed to `{workspaceDir}/.moss/analytics/traces.jsonl`. - -- [ ] **Step 1: Write the failing test** - -Create `packages/moss-agent/test/observability-file-trace.spec.mjs`: - -```javascript -#!/usr/bin/env node -// @rdk-moss/agent — FileSpanProcessor writes one JSONL line per ended span. -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const dir = path.dirname(fileURLToPath(import.meta.url)); -const distJs = path.join(dir, '..', 'dist', 'index.js'); -const mod = await import(pathToFileURL(distJs).href); -const { FileSpanProcessor } = mod; - -// Build a minimal ReadableSpan-shaped object the processor will accept. -// The processor only reads .name, .attributes, .startTime, .endTime (hrTime), -// .status, .events. We feed numbers for hrTime (ms) for simplicity. -function fakeSpan(name, attrs) { - return { - name, - attributes: attrs, - startTime: [0, 0], - endTime: [0, 1_000_000], - status: { code: 1 }, - events: [], - spanContext: () => ({ traceId: '0'.repeat(32), spanId: '0'.repeat(16) }), - }; +): Record { + return { runId, toolName, toolCallId }; } -const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-fp-')); -const proc = new FileSpanProcessor(tmpDir); - -proc.onStart(); -proc.onEnd(fakeSpan('moss.tool.invoke', { toolName: 'bash' })); -proc.onEnd(fakeSpan('moss.llm.request', { model: 'm' })); -await proc.forceFlush(); - -const file = path.join(tmpDir, '.moss', 'analytics', 'traces.jsonl'); -const content = await fs.readFile(file, 'utf-8'); -const lines = content.split('\n').filter(Boolean); -assert.equal(lines.length, 2, 'two spans → two JSONL lines'); -const first = JSON.parse(lines[0]); -assert.equal(first.name, 'moss.tool.invoke', 'first line is the first span'); -assert.equal(first.attributes.toolName, 'bash', 'attributes serialized'); - -// shutdown flushes remaining + clears timer -proc.onEnd(fakeSpan('moss.session', {})); -await proc.shutdown(); -const content2 = await fs.readFile(file, 'utf-8'); -assert.equal(content2.split('\n').filter(Boolean).length, 3, 'shutdown flushed the 3rd span'); - -await fs.rm(tmpDir, { recursive: true, force: true }); -console.log('observability-file-trace: OK'); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `node packages/moss-agent/test/observability-file-trace.spec.mjs` -Expected: FAIL — `FileSpanProcessor` is `undefined` (not exported). - -- [ ] **Step 3: Write minimal implementation** - -Create `packages/moss-agent/src/observability/file-trace.ts`: - -```typescript -/** - * FileSpanProcessor — mirrors ended spans to a local JSONL file. - * - * Sits alongside the OTLP trace exporter (BatchSpanProcessor) on the same - * tracer: every ended span is both sent to the receiver and appended to - * {workspaceDir}/.moss/analytics/traces.jsonl. Buffered + flushed every 30s - * and on shutdown. Never throws — file I/O is best-effort. - */ -import type { SpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -const FLUSH_INTERVAL_MS = 30_000; - -// hrTime is [seconds, nanoseconds]. Reduce to ms epoch-ish number for the file. -function hrToMs(hr: [number, number]): number { - return hr[0] * 1000 + hr[1] / 1_000_000; +export function llmRequestAttributes( + runId: string, + model: string, + inputTokens: number, +): Record { + return { runId, model, inputTokens }; } -function serializeSpan(span: ReadableSpan): string { - return JSON.stringify({ - name: span.name, - startTime: hrToMs(span.startTime as [number, number]), - endTime: hrToMs(span.endTime as [number, number]), - attributes: span.attributes ?? {}, - events: (span.events ?? []).map((e) => ({ - name: e.name, - time: hrToMs(e.time as [number, number]), - attrs: e.attributes ?? {}, - })), - status: span.status?.code === 2 ? 'error' : 'ok', - ...(span.status?.message ? { statusMessage: span.status.message } : {}), - }); +export function sessionAttributes( + runId: string, + model: string, + sessionKey: string, +): Record { + return { runId, model, sessionKey }; } -export class FileSpanProcessor implements SpanProcessor { - private buffer: ReadableSpan[] = []; - private timer: ReturnType | null = null; - private readonly file: string; - - constructor(workspaceDir: string) { - this.file = path.join(workspaceDir, '.moss', 'analytics', 'traces.jsonl'); - this.timer = setInterval(() => { - void this.flush(); - }, FLUSH_INTERVAL_MS); - } - - onStart(): void { - /* nothing — we serialize on end */ - } +// ── Legacy noop shims (do not remove — existing imports depend on them) ── - onEnd(span: ReadableSpan): void { - this.buffer.push(span); - } - - async forceFlush(): Promise { - if (this.buffer.length === 0) return; - const snapshot = this.buffer.splice(0); - const lines = snapshot.map(serializeSpan).join('\n') + '\n'; - try { - await fs.mkdir(path.dirname(this.file), { recursive: true }); - await fs.appendFile(this.file, lines, 'utf-8'); - } catch { - /* never block the agent */ - } - } - - async shutdown(): Promise { - if (this.timer) clearInterval(this.timer); - this.timer = null; - await this.forceFlush(); - } +export class TraceRegistry { + setTracer(_tracer: Tracer | 'console'): void {} + setTraceRedactor(_fn: (text: string) => string): void {} + getTracer(): Tracer { return noopTracer; } + redactMessage(text: string): string { return text; } } -``` -- [ ] **Step 4: Export from index.ts** - -In `packages/moss-agent/src/observability/index.ts`, add: +export function setTracer(_tracer: Tracer | 'console'): void { + // No-op under the SDK model. Tracer/MeterProvider is configured via + // initObservability() in observability/index.ts. +} -```typescript -export { FileSpanProcessor } from './file-trace.js'; +export function getTracer(): Tracer { + return noopTracer; +} ``` -- [ ] **Step 5: Build** +- [ ] **Step 4: build + 跑 spec 确认通过** -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent && node packages/moss-agent/test/observability-tracing.spec.mjs` +Expected: PASS,输出 `[spec] observability-tracing OK`。 -- [ ] **Step 6: Run test to verify it passes** +- [ ] **Step 5: 确认现有 import 未破(build + 全量测试)** -Run: `node packages/moss-agent/test/observability-file-trace.spec.mjs` -Expected: PASS, prints `observability-file-trace: OK`. +Run: `cd /d/moss-drobotics && npm run test -w @rdk-moss/agent` +Expected: 全部 spec 通过(cli-main/moss-agent/llm-call 的 `setTracer`/`withSpan` import 仍有效,因 shim 保留)。 -- [ ] **Step 7: Commit** +- [ ] **Step 6: Commit** ```bash -git -C /d/moss-drobotics add packages/moss-agent/src/observability/file-trace.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-file-trace.spec.mjs -git -C /d/moss-drobotics commit -m "feat(observability): add FileSpanProcessor for local JSONL trace export" +cd /d/moss-drobotics +git add packages/moss-agent/src/observability/tracing.ts packages/moss-agent/test/observability-tracing.spec.mjs +git commit -m "feat(agent): rewrite tracing.ts onto OTel SDK withSpan (legacy shims retained)" ``` --- -### Task 5: `sdk.ts` — NodeSDK assembly + init/shutdown +## Task 5: sdk.ts + index.ts — SDK 装配与公共入口 **Files:** - Create: `packages/moss-agent/src/observability/sdk.ts` -- Modify: `packages/moss-agent/src/observability/index.ts` -- Test: `packages/moss-agent/test/observability-sdk.spec.mjs` +- Rewrite: `packages/moss-agent/src/observability/index.ts` +- Test: `packages/moss-agent/test/observability-noop.spec.mjs` **Interfaces:** -- Consumes: `@opentelemetry/sdk-node` `NodeSDK`; `exporter-trace-otlp-http` `OTLPTraceExporter`; `sdk-trace-base` `BatchSpanProcessor`; `sdk-metrics` `PeriodicExportingMetricReader`; `exporter-metrics-otlp-http` `OTLPMetricExporter`; `resources` `resourceFromAttributes`; `semantic-conventions` semconv; `FileSpanProcessor` (Task 4); reads package version. -- Produces: `initObservability(opts: { workspaceDir; serviceName?; otlpUrl? }): void` and `shutdownObservability(): Promise`. When env disables it, `initObservability` is a no-op. +- Consumes: Task 2 `mossMetrics`、Task 3 `FileSpanProcessor`、`@opentelemetry/sdk-node` 等 +- Produces: `initObservability(opts)`、`shutdownObservability()`、`propagateHeaders(headers)`(web 工具用) -- [ ] **Step 1: Write the failing test** +- [ ] **Step 1: 写 spec(默认 noop:不启用时 initObservability 是 noop,且出站 headers 注入是 passthrough)** -Create `packages/moss-agent/test/observability-sdk.spec.mjs`: +Create `packages/moss-agent/test/observability-noop.spec.mjs`: ```javascript #!/usr/bin/env node -// @rdk-moss/agent — initObservability disabled path + idempotent shutdown. -// We do NOT start a real receiver here; we only assert the disabled path is -// a no-op and shutdown never throws. +// initObservability is a noop when disabled; propagateHeaders passes through when no active span. import assert from 'node:assert/strict'; -import os from 'node:os'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import path from 'node:path'; +import os from 'node:os'; import fs from 'node:fs/promises'; -import { fileURLToPath, pathToFileURL } from 'node:url'; const dir = path.dirname(fileURLToPath(import.meta.url)); -const distJs = path.join(dir, '..', 'dist', 'index.js'); -const mod = await import(pathToFileURL(distJs).href); -const { initObservability, shutdownObservability } = mod; - -const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-sdk-')); +const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'index.js')).href); +const { initObservability, shutdownObservability, propagateHeaders } = mod; -// Disabled: no MOSS_OTEL_ENABLED, no MOSS_OTEL_URL → must be a no-op, -// and must NOT create the traces.jsonl file (no SDK started). +// 不设任何 env,initObservability 应 noop(不创建文件、不抛) +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-noop-')); delete process.env.MOSS_OTEL_ENABLED; delete process.env.MOSS_OTEL_URL; -assert.doesNotThrow(() => initObservability({ workspaceDir: tmpDir })); -await shutdownObservability(); // idempotent, no-op when nothing started -const traceFile = path.join(tmpDir, '.moss', 'analytics', 'traces.jsonl'); -assert.ok(!await fs.access(traceFile).then(() => true).catch(() => false), - 'disabled path must not start file tracing'); - -await fs.rm(tmpDir, { recursive: true, force: true }); -console.log('observability-sdk: OK'); +assert.doesNotThrow(() => initObservability({ workspaceDir: tmp })); +await shutdownObservability(); + +// propagateHeaders 无 active span 时原样返回(补一个已存在的 header) +const out = propagateHeaders({ 'x-custom': '1' }); +assert.equal(out['x-custom'], '1', 'passes existing headers through'); +assert.ok(out, 'returns a headers object'); + +await fs.rm(tmp, { recursive: true, force: true }); +console.error('[spec] observability-noop OK'); ``` -- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 2: 运行确认失败** -Run: `node packages/moss-agent/test/observability-sdk.spec.mjs` -Expected: FAIL — `initObservability`/`shutdownObservability` undefined. +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent && node packages/moss-agent/test/observability-noop.spec.mjs` +Expected: FAIL(`index.js` 未导出 `initObservability` / `shutdownObservability` / `propagateHeaders`)。 -- [ ] **Step 3: Write minimal implementation** +- [ ] **Step 3: 实现 sdk.ts** Create `packages/moss-agent/src/observability/sdk.ts`: ```typescript /** - * OTel SDK assembly: one NodeSDK shared by tracing and metrics. - * - * initObservability reads env to decide what to start. When disabled it does - * nothing (no SDK, no timers) — business code's noop instruments/spans carry - * zero cost. shutdownObservability flushes all exporters/processors. + * OpenTelemetry SDK assembly — single NodeSDK for trace + metric, shared Resource. + * Local file trace attaches as a FileSpanProcessor alongside the OTLP exporter. */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { NodeSDK } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; -import { BatchSpanProcessor, type SpanProcessor } from '@opentelemetry/sdk-trace-base'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { semconv } from '@opentelemetry/semantic-conventions'; +import fs from 'node:fs'; +import path from 'node:path'; import { FileSpanProcessor } from './file-trace.js'; - -export interface InitOptions { +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; + +export interface ObservabilityConfig { + serviceName: string; + otlpUrl: string; + enabled: boolean; + metricsEnabled: boolean; + fileTraceEnabled: boolean; workspaceDir: string; - serviceName?: string; - otlpUrl?: string; } let sdk: NodeSDK | null = null; +/** Read package version from packages/moss-agent/package.json (not hardcoded). */ function readPackageVersion(): string { try { - // dist/observability/sdk.js → package.json is two levels up (dist → package root) - const here = path.dirname(fileURLToPath(import.meta.url)); - const pkgPath = path.join(here, '..', 'package.json'); - return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version ?? '0'; + const pkgPath = path.join(import.meta.dirname, '..', '..', 'package.json'); + return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version ?? '0.0.0'; } catch { - return '0'; + return '0.0.0'; } } -export function initObservability(opts: InitOptions): void { - const enabled = process.env.MOSS_OTEL_ENABLED === '1' || !!process.env.MOSS_OTEL_URL; - if (!enabled || sdk) return; - - const otlpUrl = process.env.MOSS_OTEL_URL ?? opts.otlpUrl ?? 'http://localhost:4318'; - const serviceName = process.env.MOSS_OTEL_SERVICE_NAME ?? opts.serviceName ?? 'moss'; - const metricsEnabled = process.env.MOSS_METRICS_ENABLED !== '0'; - const fileTraceEnabled = process.env.MOSS_FILE_TRACE !== '0'; - - const resource = resourceFromAttributes({ - [semconv.ATTR_SERVICE_NAME]: serviceName, - [semconv.ATTR_SERVICE_VERSION]: readPackageVersion(), - }); +export function initObservabilitySdk(cfg: ObservabilityConfig): void { + if (!cfg.enabled || sdk) return; + try { + const resource = resourceFromAttributes({ + [semconv.ATTR_SERVICE_NAME]: cfg.serviceName, + [semconv.ATTR_SERVICE_VERSION]: readPackageVersion(), + }); + + const spanProcessors: SpanProcessor[] = [ + new BatchSpanProcessor(new OTLPTraceExporter({ url: `${cfg.otlpUrl}/v1/traces` })), + ]; + if (cfg.fileTraceEnabled) { + spanProcessors.push(new FileSpanProcessor(cfg.workspaceDir)); + } - const spanProcessors: SpanProcessor[] = [ - new BatchSpanProcessor(new OTLPTraceExporter({ url: `${otlpUrl}/v1/traces` })), - ]; - if (fileTraceEnabled) { - spanProcessors.push(new FileSpanProcessor(opts.workspaceDir)); + const metricReader = cfg.metricsEnabled + ? new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${cfg.otlpUrl}/v1/metrics` }), + exportIntervalMillis: 10_000, + }) + : undefined; + + sdk = new NodeSDK({ + resource, + spanProcessors, + ...(metricReader ? { metricReader } : {}), + }); + sdk.start(); + } catch { + // Best-effort — never block the agent. Failure means no telemetry, not a crash. } - - const metricReader = metricsEnabled - ? new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter({ url: `${otlpUrl}/v1/metrics` }), - exportIntervalMillis: 10_000, - }) - : undefined; - - sdk = new NodeSDK({ - resource, - spanProcessors, - ...(metricReader ? { metricReader } : {}), - }); - sdk.start(); } -export async function shutdownObservability(): Promise { +export async function shutdownObservabilitySdk(): Promise { if (!sdk) return; - try { - await sdk.shutdown(); - } catch { - /* best-effort */ - } + try { await sdk.shutdown(); } catch { /* ignore */ } sdk = null; } ``` -- [ ] **Step 4: Export from index.ts** +- [ ] **Step 4: 重写 index.ts** -In `packages/moss-agent/src/observability/index.ts`, add: +Replace **entire** `packages/moss-agent/src/observability/index.ts` with: ```typescript -export { initObservability, shutdownObservability } from './sdk.js'; -export type { InitOptions } from './sdk.js'; -``` +/** + * Observability public entrypoint. + * + * initObservability() wires up OTel SDK (trace + metric + local file trace) + * based on env vars; when disabled it is a no-op and withSpan / mossMetrics.* + * run as no-ops. Call once at CLI startup (before agent creation), call + * shutdownObservability() on exit to flush. + */ +import { initObservabilitySdk, shutdownObservabilitySdk } from './sdk.js'; -- [ ] **Step 5: Build** +export interface InitOptions { + workspaceDir: string; + serviceName?: string; + otlpUrl?: string; +} -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. +export function initObservability(opts: InitOptions): void { + const enabled = process.env.MOSS_OTEL_ENABLED === '1' || !!process.env.MOSS_OTEL_URL; + if (!enabled) return; + initObservabilitySdk({ + serviceName: process.env.MOSS_OTEL_SERVICE_NAME ?? opts.serviceName ?? 'moss', + otlpUrl: process.env.MOSS_OTEL_URL ?? opts.otlpUrl ?? 'http://localhost:4318', + enabled: true, + // tracing 开则 metrics 默认开(纠正 from-remote 两开关易漏配) + metricsEnabled: process.env.MOSS_METRICS_ENABLED !== '0', + // 本地文件 trace 默认开,MOSS_FILE_TRACE=0 关 + fileTraceEnabled: process.env.MOSS_FILE_TRACE !== '0', + workspaceDir: opts.workspaceDir, + }); +} -- [ ] **Step 6: Run test to verify it passes** +export { shutdownObservabilitySdk as shutdownObservability } from './sdk.js'; -Run: `node packages/moss-agent/test/observability-sdk.spec.mjs` -Expected: PASS, prints `observability-sdk: OK`. +export { + withSpan, + turnAttributes, + toolAttributes, + llmRequestAttributes, + sessionAttributes, +} from './tracing.js'; +export { mossMetrics } from './metrics.js'; +export { FileSpanProcessor, readTraceStats } from './file-trace.js'; +export type { SerializedSpan, TraceStats } from './file-trace.js'; -- [ ] **Step 7: Commit** +// Re-export unchanged modules (drobotics already has these, identical to from-remote) +export { redactSensitiveData, parseTelemetryAllow } from './redact.js'; +export type { RedactOptions } from './redact.js'; +export { + logLLMUsage, + readUsageLog, + summarizeUsage, + formatUsageSummary, + estimateLLMCost, + registerModelPricing, +} from './llm-usage.js'; +export type { LLMUsageRecord, LLMUsageSummary } from './llm-usage.js'; + +// Legacy re-exports kept so cli-main's `import { setTracer } from './observability/tracing.js'` +// style imports continue to resolve through index too. +export { setTracer, getTracer, TraceRegistry } from './tracing.js'; +export type { Tracer, TraceSpan } from './tracing.js'; + +/** + * Inject W3C traceparent into outbound fetch headers for the current span. + * Returns headers unchanged when no active span (graceful degradation). + */ +import { propagation } from '@opentelemetry/api'; +export function propagateHeaders( + headers: Record = {}, +): Record { + try { + const injected: Record = { ...headers }; + propagation.inject(injected, { + set: (carrier, key, value) => { carrier[key] = String(value); }, + get: (carrier, key) => carrier[key], + }); + return injected; + } catch { + return headers; + } +} +``` + +- [ ] **Step 5: build + 跑 spec 确认通过** + +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent && node packages/moss-agent/test/observability-noop.spec.mjs` +Expected: PASS,输出 `[spec] observability-noop OK`。 + +- [ ] **Step 6: Commit** ```bash -git -C /d/moss-drobotics add packages/moss-agent/src/observability/sdk.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-sdk.spec.mjs -git -C /d/moss-drobotics commit -m "feat(observability): add NodeSDK assembly with shared trace+metrics+file exporters" +cd /d/moss-drobotics +git add packages/moss-agent/src/observability/sdk.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-noop.spec.mjs +git commit -m "feat(agent): add sdk.ts assembly + observability public entrypoint" ``` --- -### Task 6: `trace-context.ts` — `injectTraceHeaders` helper +## Task 6: web-fetch / web-search 注入 traceparent **Files:** -- Create: `packages/moss-agent/src/observability/trace-context.ts` -- Modify: `packages/moss-agent/src/observability/index.ts` -- Test: `packages/moss-agent/test/observability-trace-context.spec.mjs` +- Modify: `packages/moss-agent/src/tools/web-fetch.ts`(line 541 附近 fetch headers) +- Modify: `packages/moss-agent/src/tools/web-search.ts`(line 233 附近 fetch headers) **Interfaces:** -- Consumes: `@opentelemetry/api` `propagation`, `defaultTextMapSetter`. -- Produces: `injectTraceHeaders(headers: Record): Record` — injects W3C `traceparent` when inside a span, returns headers unchanged otherwise. +- Consumes: Task 5 `propagateHeaders` +- Produces: 出站 HTTP 请求带 W3C traceparent(无 active span 时 passthrough) -- [ ] **Step 1: Write the failing test** +- [ ] **Step 1: web-fetch.ts — import propagateHeaders** -Create `packages/moss-agent/test/observability-trace-context.spec.mjs`: +在 `packages/moss-agent/src/tools/web-fetch.ts` 现有 import 区(line 16-22 之后)加: -```javascript -#!/usr/bin/env node -// @rdk-moss/agent — injectTraceHeaders is a passthrough when no span is active -// (graceful degradation), and injects traceparent when inside a withSpan. -import assert from 'node:assert/strict'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import path from 'node:path'; +```typescript +import { propagateHeaders } from '../observability/index.js'; +``` -const dir = path.dirname(fileURLToPath(import.meta.url)); -const distJs = path.join(dir, '..', 'dist', 'index.js'); -const mod = await import(pathToFileURL(distJs).href); -const { injectTraceHeaders, withSpan } = mod; - -// Outside any span: headers returned unchanged (no active context → nothing injected) -const plain = injectTraceHeaders({ accept: 'text/html' }); -assert.deepEqual(plain, { accept: 'text/html' }, 'no active span → passthrough'); -assert.ok(!plain.traceparent, 'no traceparent when no span active'); - -// Inside a withSpan: traceparent is injected (SDK noop tracer may not inject a -// real one, so we only assert the function doesn't throw and returns an object). -await withSpan('test', {}, async () => { - const h = injectTraceHeaders({ accept: 'text/html' }); - assert.equal(typeof h, 'object'); - assert.equal(h.accept, 'text/html', 'existing headers preserved'); -}); +- [ ] **Step 2: web-fetch.ts — 包裹 fetch headers** -console.log('observability-trace-context: OK'); -``` +定位 line 541 附近的 `headers: {` 块(`fetchInit` 内)。改为在发起 fetch 前注入。找到 `res = await fetch(fetchUrl.toString(), fetchInit);`(line 552 附近),在其前面加: -- [ ] **Step 2: Run test to verify it fails** +```typescript + if (fetchInit.headers && typeof fetchInit.headers === 'object' && !Array.isArray(fetchInit.headers)) { + fetchInit.headers = propagateHeaders(fetchInit.headers as Record); + } +``` -Run: `node packages/moss-agent/test/observability-trace-context.spec.mjs` -Expected: FAIL — `injectTraceHeaders` undefined. +(放在 `fetchInit` 已构造完成、`await fetch` 之前。若 `fetchInit.headers` 不存在则跳过,不影响原逻辑。) -- [ ] **Step 3: Write minimal implementation** +- [ ] **Step 3: web-search.ts — import propagateHeaders** -Create `packages/moss-agent/src/observability/trace-context.ts`: +在 `packages/moss-agent/src/tools/web-search.ts` 现有 import 区(line 32-35 之后)加: ```typescript -/** - * Inject W3C traceparent for the active span into outbound request headers. - * Uses the SDK propagator, so it's consistent with span context propagation. - * No active span → headers returned unchanged (graceful degradation). - */ -import { propagation, defaultTextMapSetter } from '@opentelemetry/api'; - -export function injectTraceHeaders( - headers: Record, -): Record { - try { - propagation.inject(headers, defaultTextMapSetter); - } catch { - /* never break an outbound request over tracing */ - } - return headers; -} +import { propagateHeaders } from '../observability/index.js'; ``` -- [ ] **Step 4: Export from index.ts** +- [ ] **Step 4: web-search.ts — 包裹主 fetch headers** -In `packages/moss-agent/src/observability/index.ts`, add: +定位 line 233 附近 `const res = await fetch(url, { ...init, signal: controller.signal });`。在它前面加: ```typescript -export { injectTraceHeaders } from './trace-context.js'; + const headersWithTrace = propagateHeaders((init?.headers ?? {}) as Record); + const res = await fetch(url, { ...init, headers: headersWithTrace, signal: controller.signal }); ``` -- [ ] **Step 5: Build** +(替换原 line 233 那一行。其余 line 272/370/477/591/675/735/798 的 `headers:` 块是 RSS/feed 子请求,本 task 不动——主搜索请求注入即可,避免过度改动。) + +- [ ] **Step 5: build 确认无类型错误** -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent` +Expected: build 成功。 -- [ ] **Step 6: Run test to verify it passes** +- [ ] **Step 6: 跑全量测试确认 web 工具无回归** -Run: `node packages/moss-agent/test/observability-trace-context.spec.mjs` -Expected: PASS, prints `observability-trace-context: OK`. +Run: `cd /d/moss-drobotics && npm run test -w @rdk-moss/agent` +Expected: 全部 spec 通过。 - [ ] **Step 7: Commit** ```bash -git -C /d/moss-drobotics add packages/moss-agent/src/observability/trace-context.ts packages/moss-agent/src/observability/index.ts packages/moss-agent/test/observability-trace-context.spec.mjs -git -C /d/moss-drobotics commit -m "feat(observability): add injectTraceHeaders for outbound W3C traceparent" +cd /d/moss-drobotics +git add packages/moss-agent/src/tools/web-fetch.ts packages/moss-agent/src/tools/web-search.ts +git commit -m "feat(agent): inject W3C traceparent into web-fetch / web-search outbound requests" ``` --- -### Task 7: Wire `initObservability` + `shutdownObservability` into cli-main.ts +## Task 7: cli-main.ts — initObservability 初始化 + 退出 flush **Files:** -- Modify: `packages/moss-agent/src/cli-main.ts` (imports ~line 65; init before line 611 `new MossAgent`; shutdown in the `finally` block ~line 1014) +- Modify: `packages/moss-agent/src/cli-main.ts`(line 65 附近 import;line 611 `new MossAgent` 之前;main finally / 退出) **Interfaces:** -- Consumes: `initObservability`, `shutdownObservability` from Task 5. +- Consumes: Task 5 `initObservability` / `shutdownObservability` +- Produces: CLI 启动时按 env 装配 SDK;退出时 flush 残留 span/metric -- [ ] **Step 1: Add imports** +- [ ] **Step 1: 加 import** + +在 `cli-main.ts` line 65 `import { setTracer } from './observability/tracing.js';` 之后加: -In `packages/moss-agent/src/cli-main.ts`, find the existing import at line 65: -```typescript -import { setTracer } from './observability/tracing.js'; -``` -Replace it with: ```typescript -import { setTracer } from './observability/tracing.js'; import { initObservability, shutdownObservability } from './observability/index.js'; ``` -- [ ] **Step 2: Call init before agent construction** +- [ ] **Step 2: agent 创建前调 initObservability** -Find line 611 `const agent = new MossAgent({`. Immediately BEFORE it (after the `enableOtelMetrics`-equivalent region does not exist yet in drobotics; place it right before the `const agent = new MossAgent({` line), insert: +定位 line 611 附近 `const agent = new MossAgent({`。在它**之前**(line 608-610 区域,`// Enable OTel ...` 注释块如有也一并替换)插入: ```typescript - // Initialize observability (tracing + metrics + local file trace). - // No-op unless MOSS_OTEL_ENABLED=1 or MOSS_OTEL_URL is set. + // Enable observability (OTel tracing + metrics + local file trace) based on env. + // No-op when MOSS_OTEL_ENABLED is unset and MOSS_OTEL_URL absent. initObservability({ workspaceDir: workspace }); - - const agent = new MossAgent({ ``` -(Keep the existing `const agent = new MossAgent({` and everything after unchanged. The `workspace` variable is already in scope at this point — it was resolved earlier at line 465.) +(`workspace` 变量在 line 465 已定义,此处可见。) + +- [ ] **Step 3: 注册退出 flush** -- [ ] **Step 3: Call shutdown in the finally block** +`main()` 已有 `try { ... } finally { await closeMcpConnections(mcpConnections); }`(line 759-1016 区域)。在 finally 块内 `closeMcpConnections` 之后加 shutdown: -Find the `} finally {` block at line 1014 containing `await closeMcpConnections(mcpConnections);`. Add the shutdown call so the block reads: +定位 `} finally {` 接 `await closeMcpConnections(mcpConnections);`(line 1014-1015 附近),改为: ```typescript } finally { @@ -878,474 +983,449 @@ Find the `} finally {` block at line 1014 containing `await closeMcpConnections( } ``` -- [ ] **Step 4: Build** +- [ ] **Step 4: 兜底 beforeExit flush(异常退出路径)** + +在 `cli-main.ts` 末尾 `main().catch(...)`(line 1019)**之前**加: + +```typescript +process.on('beforeExit', () => { void shutdownObservability(); }); +``` -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. +- [ ] **Step 5: build 确认无类型错误** -- [ ] **Step 5: Run full test suite** +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent` +Expected: build 成功。 -Run: `npm test -w @rdk-moss/agent` -Expected: all specs pass (no behavioral change — init is no-op without env). +- [ ] **Step 6: 跑全量测试确认无回归** -- [ ] **Step 6: Commit** +Run: `cd /d/moss-drobotics && npm run test -w @rdk-moss/agent` +Expected: 全部 spec 通过。 + +- [ ] **Step 7: Commit** ```bash -git -C /d/moss-drobotics add packages/moss-agent/src/cli-main.ts -git -C /d/moss-drobotics commit -m "feat(cli): wire observability init/shutdown into main lifecycle" +cd /d/moss-drobotics +git add packages/moss-agent/src/cli-main.ts +git commit -m "feat(agent): wire initObservability at CLI startup + flush on exit" ``` --- -### Task 8: `moss.session` span + session metrics in moss-agent.ts +## Task 8: agent-loop-llm-call.ts — span 改名 + llm metrics **Files:** -- Modify: `packages/moss-agent/src/core/agent/moss-agent.ts` (imports line 39; `chat()` at line 412) -- Test: `packages/moss-agent/test/observability-session-span.spec.mjs` +- Modify: `packages/moss-agent/src/core/loop/agent-loop-llm-call.ts`(line 22 import;line 133 withSpan;success/catch 处加 metrics) **Interfaces:** -- Consumes: `withSpan`, `sessionAttributes`, `mossMetrics` from observability index. +- Consumes: Task 4 `withSpan`/`turnAttributes`、Task 2 `mossMetrics` +- Produces: `moss.llm.request` span + `moss.llm.tokens` / `moss.llm.request.duration` metric -- [ ] **Step 1: Write the failing test** +- [ ] **Step 1: import mossMetrics** -Create `packages/moss-agent/test/observability-session-span.spec.mjs`: +在 line 22 `import { withSpan, turnAttributes } from '../../observability/tracing.js';` 之后加: -```javascript -#!/usr/bin/env node -// @rdk-moss/agent — chat() wraps its work in a moss.session span and records -// session metrics on completion. Without the SDK started, withSpan is noop but -// still runs the wrapped fn, so chat() behavior is unchanged. We assert that -// the metrics handle is touched (noop add/record don't throw) and chat still -// returns a ChatResult shape. This is a smoke test, not a span-content test — -// real span content is verified end-to-end in the manual verification task. -import assert from 'node:assert/strict'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import path from 'node:path'; - -const dir = path.dirname(fileURLToPath(import.meta.url)); -const distJs = path.join(dir, '..', 'dist', 'index.js'); -const mod = await import(pathToFileURL(distJs).href); -const { mossMetrics } = mod; - -// metrics handle is callable (noop until SDK init) -assert.doesNotThrow(() => { - mossMetrics.sessionCount.add(1, { outcome: 'agent_loop_done' }); - mossMetrics.sessionDuration.record(123, { outcome: 'agent_loop_done' }); - mossMetrics.sessionToolCount.record(2, { outcome: 'agent_loop_done' }); -}); - -console.log('observability-session-span: OK'); +```typescript +import { mossMetrics } from '../../observability/index.js'; ``` -- [ ] **Step 2: Run test to verify it fails** - -Run: `node packages/moss-agent/test/observability-session-span.spec.mjs` -Expected: it may already PASS (only checks metrics handle). That's fine — this task's real value is wiring the span, verified by build + suite. Keep the test as a regression guard for the metrics outcome labels. +- [ ] **Step 2: span 名改 moss.llm.request** -- [ ] **Step 3: Add imports to moss-agent.ts** +定位 line 133-135: -In `packages/moss-agent/src/core/agent/moss-agent.ts`, the existing import at line 39 is: ```typescript -import { setTraceRedactor } from '../../observability/tracing.js'; + const llmTurn = await withSpan( + 'agent.llm_turn', + turnAttributes(runId, state.turns, String(modelDef.id)), ``` -Add a new import line near it (e.g., right after line 39): + +把 `'agent.llm_turn'` 改为 `'moss.llm.request'`: + ```typescript -import { withSpan, sessionAttributes } from '../../observability/tracing.js'; -import { mossMetrics } from '../../observability/index.js'; + const llmTurn = await withSpan( + 'moss.llm.request', + turnAttributes(runId, state.turns, String(modelDef.id)), ``` -- [ ] **Step 4: Wrap chat() body in moss.session span** +- [ ] **Step 3: success 路径记 metrics** -Read `packages/moss-agent/src/core/agent/moss-agent.ts` around line 412 (`async chat(...)`). The method body runs the agent loop and assembles `finalResult`. Wrap the body so the whole run is under one span and metrics are recorded at the end. Concretely, find the method's main execution (the call that produces `finalResult`, e.g. the `runAgentLoop`/loop invocation) and wrap it: +定位 line 177-191 区域 `if (llmTurn.usage) {` 块末尾、`return { control: 'continue', ...` 之前(line 191 `});` 之后),在 `recordLlmUsage(...)` 调用之后插入 metrics 记录: ```typescript - async chat(sessionKey: string, userMessage: string, options?: ChatOptions): Promise { - let finalResult: ChatResult | undefined; - const sessionStartMs = Date.now(); - const runId = options?.runId ?? `r-${Date.now()}`; - const model = String(this.config.model); + // Metrics (noop when metrics disabled) + const llmModel = String(modelDef.id); + const llmDuration = Date.now() - llmTurnStartedAt; + mossMetrics.llmDuration.record(llmDuration, { model: llmModel }); + mossMetrics.llmTokens.add(llmTurn.usage.inputTokens, { direction: 'input', model: llmModel }); + mossMetrics.llmTokens.add(llmTurn.usage.outputTokens, { direction: 'output', model: llmModel }); +``` - finalResult = await withSpan( - 'moss.session', - sessionAttributes(runId, model, sessionKey), - async () => { - // ── existing chat() body that produces the result goes here ── - // (the original logic that sets finalResult / returns ChatResult) - return /* original return value or finalResult assembly */; - }, - ); +- [ ] **Step 4: catch 路径记 metrics** - // Record session metrics on completion. - const outcome = finalResult?.stopReason === 'max_turns_reached' ? 'max_turns' : 'agent_loop_done'; - mossMetrics.sessionCount.add(1, { outcome }); - mossMetrics.sessionDuration.record(Date.now() - sessionStartMs, { outcome }); - mossMetrics.sessionToolCount.record(finalResult?.toolCalls?.length ?? 0, { outcome }); +定位 line 213-223 区域 `} catch (llmError) {` 内的 `await recordLlmUsage({... success: false ...});` 之后(line 223 `});` 之后),插入: - return finalResult; - } +```typescript + // Metrics: record failed LLM call + mossMetrics.llmDuration.record(Date.now() - llmTurnStartedAt, { model: String(modelDef.id) }); ``` -**IMPORTANT — this is a structural wrap, not a literal paste.** The implementer must: -1. Read the full current `chat()` method (lines ~412–550) first. -2. Identify the single expression/statement that yields the `ChatResult` (the existing return path or `finalResult` assignment). -3. Move that body inside the `withSpan` callback, returning its value from the callback. -4. Keep `runId`/`model` extraction consistent with whatever the method already uses (if `runId` is already computed in-scope, reuse it; do not duplicate). If the method already has a `runId` variable, use that name instead of redeclaring. -5. Preserve the existing return type and all side effects (event pushes, persistence). - -If wrapping the whole body is too invasive given the method's structure, the acceptable alternative is to wrap only the top-level loop invocation (the `runAgentLoop` or equivalent call) — the session span then covers the actual agent work, which is the goal. Record metrics immediately after that call resolves. - -- [ ] **Step 5: Build** +- [ ] **Step 5: build 确认无类型错误** -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. If `runId`/`model` naming collides with existing locals, rename the new locals to avoid shadowing. +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent` +Expected: build 成功。 -- [ ] **Step 6: Run full test suite** +- [ ] **Step 6: 跑全量测试** -Run: `npm test -w @rdk-moss/agent` -Expected: all specs pass. If a behavior test fails, the wrap altered a side effect — fix by moving side-effectful statements outside the span callback (they should run, just not under the span) or by ensuring the callback returns the value the method returns. +Run: `cd /d/moss-drobotics && npm run test -w @rdk-moss/agent` +Expected: 全部 spec 通过。 - [ ] **Step 7: Commit** ```bash -git -C /d/moss-drobotics add packages/moss-agent/src/core/agent/moss-agent.ts packages/moss-agent/test/observability-session-span.spec.mjs -git -C /d/moss-drobotics commit -m "feat(agent): wrap chat() in moss.session span + record session metrics" +cd /d/moss-drobotics +git add packages/moss-agent/src/core/loop/agent-loop-llm-call.ts +git commit -m "feat(agent): rename llm span to moss.llm.request + emit llm metrics" ``` --- -### Task 9: `moss.agent.turn` span in agent-loop.ts +## Task 9: execute-tool-call.ts — tool span + tool metrics **Files:** -- Modify: `packages/moss-agent/src/core/loop/agent-loop.ts` (imports; turn iteration around line 426 `executeLlmTurn`) +- Modify: `packages/moss-agent/src/core/tools/execute-tool-call.ts`(line 30 附近 import;line 341 `startMs` 之后包 span;outcome 处记 metrics) **Interfaces:** -- Consumes: `withSpan`, `turnAttributes` from observability tracing. +- Consumes: Task 4 `withSpan`/`toolAttributes`、Task 2 `mossMetrics` +- Produces: `moss.tool.invoke` span + `moss.tool.invocations` / `moss.tool.invoke.duration` metric + +- [ ] **Step 1: import** -- [ ] **Step 1: Add imports** +在 line 30 `import { runPreToolHookChain, validateToolInputObject } from './tool-pipeline.js';` 之后加: -In `packages/moss-agent/src/core/loop/agent-loop.ts`, add near the top imports: ```typescript -import { withSpan, turnAttributes } from '../../observability/tracing.js'; +import { withSpan, toolAttributes } from '../../observability/tracing.js'; +import { mossMetrics } from '../../observability/index.js'; ``` -- [ ] **Step 2: Wrap each turn iteration in a turn span** +- [ ] **Step 2: 在 executeOneToolCall 内包 span + 记 metrics** + +定位 line 341 `const startMs = Date.now();`。它位于 `executeOneToolCall`(line 242 起)函数体内、`return { kind: 'completed', ... }`(line 540 附近)之前。 -Find the turn loop body. From exploration: line 330 `outerLoop: while (true)`, line 356 `state.turns++`, then around line 426 `const llmResult = await executeLlmTurn({...})` followed by `processLlmResponse` (line 460) and the control-flow checks. Wrap the per-turn work (from after `state.turns++` through the `continue`/`break` decision) in a span: +把 line 341 到函数 return 之间的主执行体用 `withSpan` 包裹。由于该函数体较长,采用**最小侵入**改法:在 `const startMs = Date.now();` 之后、原执行体最外层逻辑之外,用 withSpan 包住整个 try/catch。 -Locate the block starting after `state.turns++;` (line 356) and the `stream.push({ type: 'turn_start', ... })`. Wrap the turn body: +实际改法——在 line 341 `const startMs = Date.now();` 之后插入: ```typescript - state.turns++; - stream.push({ type: 'turn_start', turn: state.turns }); - - await withSpan( - 'moss.agent.turn', - turnAttributes(runId, state.turns, String(modelDef.id)), - async () => { - // ── existing per-turn body: ctxResult, executeLlmTurn, processLlmResponse, - // control-flow checks (continue/break) ── - // Keep all existing statements here verbatim. - }, - ); + return withSpan( + 'moss.tool.invoke', + toolAttributes(deps.sessionKey, call.name, call.id), + async (span) => { + try { + const outcome = await executeOneToolCallInner(call, deps); + const isErr = outcome.kind === 'completed' ? Boolean(outcome.isError) : true; + const dur = outcome.kind === 'completed' ? outcome.durationMs : Date.now() - startMs; + span.setAttribute('is_error', isErr); + if (outcome.kind === 'completed' && outcome.outcome) span.setAttribute('outcome', outcome.outcome); + mossMetrics.toolInvocations.add(1, { tool: call.name, status: isErr ? 'error' : 'ok' }); + mossMetrics.toolDuration.record(dur, { tool: call.name }); + return outcome; + } catch (err) { + mossMetrics.toolInvocations.add(1, { tool: call.name, status: 'error' }); + mossMetrics.toolDuration.record(Date.now() - startMs, { tool: call.name }); + throw err; + } + }, + ); ``` -**Implementation note:** The turn body contains `continue`/`break` statements that target the outer `outerLoop`/inner `while` loops. Moving them inside an async callback changes control flow — `continue`/`break` cannot appear inside an arrow function. Therefore: -- The span callback should contain ONLY the work that produces the turn's outcome (the LLM call + response processing + tool dispatch) and RETURN a control signal (`'continue' | 'break' | 'retry'`) rather than using `continue`/`break`. -- After the `withSpan` call, act on the returned signal with a real `continue`/`break` in the loop. +然后把**原** line 342 到 return 的函数体重命名为内部函数 `executeOneToolCallInner`: -Concretely, restructure so the callback returns the existing `control` value and the loop body maps it: +- 在 `export async function executeOneToolCall(...)` 签名之后、`const startMs = Date.now();` **之前**,原函数体不变; +- 将原顶层 `export async function executeOneToolCall` 改为 `async function executeOneToolCallInner`(去掉 export); +- 在其**之后**新增对外导出的 `executeOneToolCall`,它只做「调 inner + 包 span/metrics」: ```typescript - const turnControl = await withSpan( - 'moss.agent.turn', - turnAttributes(runId, state.turns, String(modelDef.id)), - async () => { - // existing body up to where it decides control, returning: - // 'continue' (was: continue;) - // 'break' (was: break;) - // 'retry' (was: state.turns--; continue;) - return control; // the value the body already computes - }, - ); - if (turnControl === 'break') break; - if (turnControl === 'retry') { state.turns--; continue; } - continue; +export async function executeOneToolCall( + call: ExecuteToolCallRef, + deps: ExecuteToolCallDeps, +): Promise { + const startMs = Date.now(); + return withSpan( + 'moss.tool.invoke', + toolAttributes(deps.sessionKey, call.name, call.id), + async (span) => { + try { + const outcome = await executeOneToolCallInner(call, deps); + const isErr = outcome.kind === 'completed' ? Boolean(outcome.isError) : true; + const dur = outcome.kind === 'completed' ? outcome.durationMs : Date.now() - startMs; + span.setAttribute('is_error', isErr); + if (outcome.kind === 'completed' && outcome.outcome) span.setAttribute('outcome', outcome.outcome); + mossMetrics.toolInvocations.add(1, { tool: call.name, status: isErr ? 'error' : 'ok' }); + mossMetrics.toolDuration.record(dur, { tool: call.name }); + return outcome; + } catch (err) { + mossMetrics.toolInvocations.add(1, { tool: call.name, status: 'error' }); + mossMetrics.toolDuration.record(Date.now() - startMs, { tool: call.name }); + throw err; + } + }, + ); +} ``` -If the existing body already uses a `control`-returning helper pattern (it does — `executeLlmTurn` returns `LoopControlSignal`, `processLlmResponse` returns a control), this is mostly hoisting those returns out of the loop body into the callback and switching on the result. Read lines 330–520 carefully before editing. +> 实现者注意:`ExecuteToolCallRef` / `ExecuteToolCallDeps` / `ExecuteToolCallOutcome` 是原函数已有的类型名(见文件内 `import type` 与函数签名),原样沿用;`outcome.outcome` 是 `ToolResultOutcome` 字符串字段(见 line 219-221)。若 TS 报 outcome 类型不匹配,按文件内实际类型调整 attribute 取值,不改控制流。 -- [ ] **Step 3: Build** +- [ ] **Step 3: build 确认无类型错误** -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. Watch for "continue/break not allowed in arrow function" TS errors — those mean a `continue`/`break` was left inside the callback; convert it to a returned signal as above. +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent` +Expected: build 成功(如有类型错误,按文件内实际类型修正 attribute 取值,不改 span 包裹与 metrics 逻辑)。 -- [ ] **Step 4: Run full test suite** +- [ ] **Step 4: 跑全量测试** -Run: `npm test -w @rdk-moss/agent` -Expected: all specs pass. If a loop-control test fails, a signal was mis-mapped (e.g. `retry` handled as `continue`); fix the mapping to match the original semantics exactly. +Run: `cd /d/moss-drobotics && npm run test -w @rdk-moss/agent` +Expected: 全部 spec 通过(tool 执行相关 spec 不受影响——内层逻辑未变)。 - [ ] **Step 5: Commit** ```bash -git -C /d/moss-drobotics add packages/moss-agent/src/core/loop/agent-loop.ts -git -C /d/moss-drobotics commit -m "feat(loop): wrap each agent-loop turn in moss.agent.turn span" +cd /d/moss-drobotics +git add packages/moss-agent/src/core/tools/execute-tool-call.ts +git commit -m "feat(agent): wrap tool execution in moss.tool.invoke span + emit tool metrics" ``` --- -### Task 10: Rename LLM span + add LLM metrics in agent-loop-llm-call.ts +## Task 10: agent-loop.ts — moss.agent.turn span **Files:** -- Modify: `packages/moss-agent/src/core/loop/agent-loop-llm-call.ts` (import line 22; span line 133; metrics after usage at ~line 177) +- Modify: `packages/moss-agent/src/core/loop/agent-loop.ts`(line 36 附近 import;主循环 line 330 `outerLoop: while (true)` 内每轮迭代外包 span) **Interfaces:** -- Consumes: `mossMetrics` from observability index; existing `withSpan`/`turnAttributes`. +- Consumes: Task 4 `withSpan`/`turnAttributes`、`runId`(循环内已有变量) +- Produces: `moss.agent.turn` span(每轮一个) -- [ ] **Step 1: Add metrics import** +- [ ] **Step 1: import** -At line 22 the file imports: -```typescript -import { withSpan, turnAttributes } from '../../observability/tracing.js'; -``` -Add after it: -```typescript -import { mossMetrics } from '../../observability/index.js'; -``` +定位 line 36 `import { executeLlmTurn } from './agent-loop-llm-call.js';` 附近,在循环文件 import 区加: -- [ ] **Step 2: Rename the span** - -At line 133, change: -```typescript - const llmTurn = await withSpan( - 'agent.llm_turn', - turnAttributes(runId, state.turns, String(modelDef.id)), -``` -to: ```typescript - const llmTurn = await withSpan( - 'moss.llm.request', - turnAttributes(runId, state.turns, String(modelDef.id)), +import { withSpan, turnAttributes } from '../../observability/tracing.js'; ``` -- [ ] **Step 3: Add LLM metrics on success** +- [ ] **Step 2: 每轮迭代外包 span** -Find the block around line 177 that runs when `llmTurn.usage` is present (after `await recordLlmUsage({...success: true...})`). Add metrics after that call: +定位 line 330 `outerLoop: while (true) {` 与 line 337 内层 `while (state.hasMoreToolCalls || state.pendingMessages.length > 0) {`。 -```typescript - // Metrics (noop when metrics disabled) - const _llmModel = String(modelDef.id); - const _llmDuration = Date.now() - llmTurnStartedAt; - mossMetrics.llmTokens.add(llmTurn.usage.inputTokens, { direction: 'input', model: _llmModel }); - mossMetrics.llmTokens.add(llmTurn.usage.outputTokens, { direction: 'output', model: _llmModel }); - mossMetrics.llmDuration.record(_llmDuration, { model: _llmModel }); -``` +在 line 356 `state.turns++;` 之后、该轮迭代主体(`executeLlmTurn` 等,line 426)之前,用 `withSpan` 包住单轮的 LLM+工具处理。由于 `outerLoop` / 内层 while 含 `break`/`continue`/`state.turns--` 等控制流,**最小侵入**做法是:在内层 while 循环体的开头(line 337 `while (...) {` 之后第一行)开 span,但要避免 break/continue 跳过 span.end。 -- [ ] **Step 4: Add LLM metrics on failure** +**采用安全包裹法**——把 line 356 `state.turns++;` 之后到该轮结束的语句提取到 `await withSpan('moss.agent.turn', ..., async (span) => { ... })` 内,并让内层 while 改为: -In the `catch (llmError)` block (around line 213), after `await recordLlmUsage({...success: false...})`, add: +在 line 356 `state.turns++;` 之后插入(替换其后到内层 while 结束的整段为 span 包体): ```typescript - // Metrics: record failed LLM call - mossMetrics.llmDuration.record(Date.now() - llmTurnStartedAt, { model: String(modelDef.id), status: 'error' }); + await withSpan('moss.agent.turn', turnAttributes(runId, state.turns, String(modelDef.id)), async () => { + // ... 原 line 358 起到内层 while 结束的全部语句(processLlmResponse / continue / break 等)原样搬入 ... + }); ``` -- [ ] **Step 5: Build** +> 实现者注意:`runId`、`modelDef` 在该作用域已定义(见 line 147 `runId`、循环参数)。内层 while 里的 `continue`/`break` 在 async 闭包内会作用于该闭包——若 `break` 需跳出 `outerLoop`,改用标志位(`let breakOuter = false;` 在闭包内设、闭包外判断)以替代原 `break outerLoop`。逐一核对 line 337-540 区域每个 `break`/`continue` 的跳转目标,确保 span 总能 end(withSpan 的 finally 保证)。不改任何条件判断与状态赋值的值。 -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. +- [ ] **Step 3: build 确认无类型错误** -- [ ] **Step 6: Run full test suite** +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent` +Expected: build 成功。若 TS 报闭包内 break/continue 作用域问题,按 Step 2 的标志位法修正,不改控制流语义。 -Run: `npm test -w @rdk-moss/agent` -Expected: all specs pass. +- [ ] **Step 4: 跑全量测试(含 agent-loop 相关 spec)** -- [ ] **Step 7: Commit** +Run: `cd /d/moss-drobotics && npm run test -w @rdk-moss/agent` +Expected: 全部 spec 通过。重点关注 `agent-loop-push-guard-isolation.spec.mjs`、`autonomous-loop.spec.mjs`。 + +- [ ] **Step 5: Commit** ```bash -git -C /d/moss-drobotics add packages/moss-agent/src/core/loop/agent-loop-llm-call.ts -git -C /d/moss-drobotics commit -m "feat(loop): rename LLM span to moss.llm.request + record LLM token/duration metrics" +cd /d/moss-drobotics +git add packages/moss-agent/src/core/loop/agent-loop.ts +git commit -m "feat(agent): wrap each loop turn in moss.agent.turn span" ``` --- -### Task 11: `moss.tool.invoke` span + tool metrics in execute-tool-call.ts +## Task 11: moss-agent.ts — moss.session 根 span + session metrics **Files:** -- Modify: `packages/moss-agent/src/core/tools/execute-tool-call.ts` (imports; `executeOneToolCall` at line 242; outcome return at ~line 543) +- Modify: `packages/moss-agent/src/core/agent/moss-agent.ts`(import 区;line 412 `chat()` 内) **Interfaces:** -- Consumes: `withSpan`, `toolAttributes`, `mossMetrics` from observability. +- Consumes: Task 4 `withSpan`/`sessionAttributes`、Task 2 `mossMetrics` +- Produces: `moss.session` 根 span + `moss.session.count` / `moss.session.duration` / `moss.session.tool_count` metric + +- [ ] **Step 1: import** -- [ ] **Step 1: Add imports** +在 `moss-agent.ts` import 区(文件头部,`from '../loop/agent-loop.js'` 等附近)加: -At the top of `packages/moss-agent/src/core/tools/execute-tool-call.ts` (after line 31), add: ```typescript -import { withSpan, toolAttributes } from '../../observability/tracing.js'; +import { withSpan, sessionAttributes } from '../../observability/tracing.js'; import { mossMetrics } from '../../observability/index.js'; ``` -- [ ] **Step 2: Wrap tool execution in a span and record metrics** +- [ ] **Step 2: chat() 内包 session span + 记 metrics** -`executeOneToolCall` (line 242) currently computes `startMs` (line 341) and returns the outcome object at ~line 543 (`return { kind: 'completed', text, isError: errFlag, durationMs: Date.now() - startMs, ... }`). Wrap the core execution so each tool call is one span and metrics are recorded on completion. +定位 line 412 `async chat(sessionKey: string, userMessage: string, options?: ChatOptions): Promise {` 与 line 413 `let finalResult: ChatResult | undefined;`。 -Find the `return { kind: 'completed', ... }` at ~line 543. Restructure the function body: wrap the execution (the part between `const startMs = Date.now();` at line 341 and the final `return { kind: 'completed', ...}`) in `withSpan`. Because the function has multiple return paths (`pre-blocked`, `completed`, and the `catch` returning `pre-blocked`), wrap only the main `completed` path: +在 `chat()` 函数体最外层用 `withSpan('moss.session', sessionAttributes(runId, model, sessionKey), ...)` 包裹主体,并在结束记 metrics。最小侵入:在 line 412 函数体开头加 `const sessionStart = Date.now();`,把原返回路径包进 withSpan。 + +具体——找到 `chat()` 的最终 `return`(返回 `ChatResult`,通常在函数末尾,`finalResult` 被赋值处)。在 line 412 `let finalResult: ChatResult | undefined;` 之后插入: ```typescript - // inside executeOneToolCall, replacing the final success return: - const _toolDuration = Date.now() - startMs; - mossMetrics.toolInvocations.add(1, { tool: call.name, status: errFlag ? 'error' : 'ok' }); - mossMetrics.toolDuration.record(_toolDuration, { tool: call.name }); - - return { - kind: 'completed', - text, - isError: errFlag, - durationMs: _toolDuration, - ...(aborted ? { aborted } : {}), - ...(structuredBlocks ? { structuredContent: structuredBlocks } : {}), - }; + const sessionStart = Date.now(); + return withSpan( + 'moss.session', + sessionAttributes(/* runId */ thisRunId, /* model */ String(model), sessionKey), + async () => { + // ... 原 chat() 主体(line 413 起到原 return)原样搬入,return 改为 return 到闭包 ... + }, + ).finally(() => { + const outcome = finalResult?.stopReason === 'end_turn' ? 'ok' : (finalResult ? 'incomplete' : 'error'); + mossMetrics.sessionCount.add(1, { outcome }); + mossMetrics.sessionDuration.record(Date.now() - sessionStart, { outcome }); + mossMetrics.sessionToolCount.record(finalResult?.toolCalls?.length ?? 0, { outcome }); + }); ``` -For the span: the cleanest non-invasive placement is to wrap the call's main work. Read the function first. If wrapping the whole body in `withSpan` would force unwinding multiple early returns, instead add an inner `withSpan('moss.tool.invoke', toolAttributes(deps.sessionKey, call.name, call.id), async () => { ... })` around the `emitStart()` → hook → execute sequence (lines ~520–543) and have the callback return the outcome; the outer function returns what the callback returns. Use the `runId` available in scope if present, else pass `deps.sessionKey` as the `runId` attribute slot (the builder just needs an id; `sessionKey` is acceptable as it's the closest stable identifier here — note this in the span attribute by leaving the field as-is). +> 实现者注意: +> - `runId` / `model` 在 `chat()` 作用域内的实际变量名按文件内为准(chat 可能在内部调用 `this.run(...)` 产生 runId;若 runId 在 withSpan 之前不可用,改用 `sessionKey` + `model` 作 attributes,`runId` 在闭包内拿到后 `span.setAttribute('runId', runId)`)。先读 line 412-570 区段确认变量可见性再填 attributes。 +> - `finalResult.toolCalls` 字段名按 `ChatResult` 类型(见 line 141 `SharedChatResult`)确认;若为 `toolsUsed` 或其他名,按实际字段。 +> - `.finally` 在 `finalResult` 赋值后执行(withSpan 的 fn return 时 finalResult 已被赋值),故 metric 能读到结果。 -- [ ] **Step 3: Build** +- [ ] **Step 3: build 确认无类型错误** -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. If `withSpan`'s async callback conflicts with the function's synchronous `return` paths, ensure every path inside the callback returns the outcome object. +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent` +Expected: build 成功。若 attributes 字段名/变量名不符,按文件实际修正,不改 span/metrics 逻辑与返回值。 -- [ ] **Step 4: Run full test suite** +- [ ] **Step 4: 跑全量测试** -Run: `npm test -w @rdk-moss/agent` -Expected: all specs pass. Tool execution tests must still pass — the span wraps the work but returns the same outcome. +Run: `cd /d/moss-drobotics && npm run test -w @rdk-moss/agent` +Expected: 全部 spec 通过。 - [ ] **Step 5: Commit** ```bash -git -C /d/moss-drobotics add packages/moss-agent/src/core/tools/execute-tool-call.ts -git -C /d/moss-drobotics commit -m "feat(tools): wrap tool execution in moss.tool.invoke span + record tool metrics" +cd /d/moss-drobotics +git add packages/moss-agent/src/core/agent/moss-agent.ts +git commit -m "feat(agent): wrap chat() in moss.session root span + emit session metrics" ``` --- -### Task 12: Inject traceparent into web-fetch + web-search +## Task 12: 端到端集成验证 **Files:** -- Modify: `packages/moss-agent/src/tools/web-fetch.ts` (headers at line 541) -- Modify: `packages/moss-agent/src/tools/web-search.ts` (headers at lines 272, 370, 477, 591, 675, 735, 798) +- Test: `packages/moss-agent/test/observability-integration.spec.mjs` **Interfaces:** -- Consumes: `injectTraceHeaders` from observability index (Task 6). - -- [ ] **Step 1: Add import to web-fetch.ts** +- Consumes: Task 5 `initObservability` / `shutdownObservability`、`withSpan`、`mossMetrics`、`readTraceStats` +- Produces: 验证三层 span + metrics + 本地文件 trace 在启用后真正产生数据 -In `packages/moss-agent/src/tools/web-fetch.ts`, after the existing imports (after line 22), add: -```typescript -import { injectTraceHeaders } from '../observability/index.js'; -``` +- [ ] **Step 1: 写集成 spec(启用 → 跑 withSpan → 文件落盘 → shutdown flush)** -- [ ] **Step 2: Inject into web-fetch headers** +Create `packages/moss-agent/test/observability-integration.spec.mjs`: -At line 541 there is a `headers: { ... }` object passed to `fetch`. Wrap it: -```typescript - headers: injectTraceHeaders({ - /* existing header keys */ - }), -``` -(Keep all existing header keys inside the object passed to `injectTraceHeaders`; it returns the same object with `traceparent` added when a span is active.) - -- [ ] **Step 3: Add import to web-search.ts** - -In `packages/moss-agent/src/tools/web-search.ts`, after line 35, add: -```typescript -import { injectTraceHeaders } from '../observability/index.js'; -``` - -- [ ] **Step 4: Inject into web-search headers** - -For each `headers: { ... }` in web-search.ts (lines 272, 370, 477, 591, 675, 735, 798), wrap the object literal with `injectTraceHeaders({ ... })`. Example for line 477: -```typescript - headers: injectTraceHeaders({ 'user-agent': opts.userAgent, accept: 'text/html' }), -``` -Apply the same wrap to every `headers:` literal in the file. Skip any `headers` that are not object literals (e.g. a variable reference) — for those, wrap at the call site: `fetch(url, { ...init, headers: injectTraceHeaders(init.headers ?? {}) })`. - -- [ ] **Step 5: Build** - -Run: `npm run build -w @rdk-moss/agent` -Expected: build succeeds. +```javascript +#!/usr/bin/env node +// Integration: enable SDK, run nested withSpan (session→turn→llm), verify file trace lands. +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; -- [ ] **Step 6: Run full test suite** +const dir = path.dirname(fileURLToPath(import.meta.url)); +const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'index.js')).href); +const { initObservability, shutdownObservability, withSpan, mossMetrics } = mod; +const traceMod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'file-trace.js')).href); +const { readTraceStats } = traceMod; + +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-int-')); +process.env.MOSS_OTEL_ENABLED = '1'; +process.env.MOSS_OTEL_URL = 'http://localhost:4318'; // receiver 可能没起,fire-and-forget 不抛 +process.env.MOSS_FILE_TRACE = '1'; + +initObservability({ workspaceDir: tmp }); + +// 三层 span:session → turn → llm(模拟 agent 调用栈) +await withSpan('moss.session', { runId: 'r1', model: 'm', sessionKey: 'sk' }, async () => { + return withSpan('moss.agent.turn', { runId: 'r1', turn: 1, model: 'm' }, async () => { + return withSpan('moss.llm.request', { runId: 'r1', model: 'm', inputTokens: 100 }, async (span) => { + span.setAttribute('outputTokens', 50); + mossMetrics.llmTokens.add(100, { direction: 'input', model: 'm' }); + mossMetrics.llmTokens.add(50, { direction: 'output', model: 'm' }); + mossMetrics.llmDuration.record(42, { model: 'm' }); + return 'done'; + }); + }); +}); -Run: `npm test -w @rdk-moss/agent` -Expected: all specs pass. +await shutdownObservability(); -- [ ] **Step 7: Commit** +const file = path.join(tmp, '.moss', 'analytics', 'traces.jsonl'); +const stats = await readTraceStats(file); +assert.equal(stats.totalSpans, 3, 'three nested spans landed in file'); +assert.ok(stats.byName['moss.session'], 'session span present'); +assert.ok(stats.byName['moss.agent.turn'], 'turn span present'); +assert.ok(stats.byName['moss.llm.request'], 'llm span present'); -```bash -git -C /d/moss-drobotics add packages/moss-agent/src/tools/web-fetch.ts packages/moss-agent/src/tools/web-search.ts -git -C /d/moss-drobotics commit -m "feat(tools): inject W3C traceparent into web-fetch/web-search outbound headers" +await fs.rm(tmp, { recursive: true, force: true }); +delete process.env.MOSS_OTEL_ENABLED; +delete process.env.MOSS_OTEL_URL; +delete process.env.MOSS_FILE_TRACE; +console.error('[spec] observability-integration OK'); ``` ---- - -### Task 13: End-to-end manual verification - -**Files:** -- None modified — verification only. +- [ ] **Step 2: 运行确认(receiver 未起也应 PASS——文件 trace 不依赖网络)** -- [ ] **Step 1: Start the local OTLP receiver** +Run: `cd /d/moss-drobotics && npm run build -w @rdk-moss/agent && node packages/moss-agent/test/observability-integration.spec.mjs` +Expected: PASS,输出 `[spec] observability-integration OK`。若因 OTLP 发送超时拖慢但不抛错属正常;若卡住,确认 OTLP exporter 是 fire-and-forget(`BatchSpanProcessor` 异步发送,shutdown 时 flush 有超时上限)。 -Run: `D:\otel\start-receiver.cmd` (or `node D:\otel\otel-receiver.mjs` in a separate window). -Expected: receiver listens on `:4318` for `/v1/traces` and `/v1/metrics`; dashboard available at `http://localhost:3000`. +- [ ] **Step 3: 跑全量测试确认整体无回归** -- [ ] **Step 2: Run moss with observability enabled** +Run: `cd /d/moss-drobotics && npm run test -w @rdk-moss/agent` +Expected: 全部 spec 通过。 -Run (in a separate shell): -```bash -cd /d/moss-drobotics -MOSS_OTEL_ENABLED=1 MOSS_OTEL_SERVICE_NAME=moss node packages/moss-agent/dist/cli.js -``` -Expected: moss starts. Send one message that triggers a tool call (e.g. a prompt that makes the agent run a shell command or web_fetch). Exit with `/exit`. +- [ ] **Step 4: 手动 smoke(可选,需 receiver)** -- [ ] **Step 3: Verify traces arrived at the receiver** +若本地 `D:\otel` receiver 可起: -Open `http://localhost:3000`. Expected: a trace rooted at `moss.session` containing nested `moss.agent.turn` → `moss.llm.request` and `moss.tool.invoke` spans, forming a tree. Span attributes include `runId`, `model`, `turn`, `toolName`, `inputTokens`, `outputTokens`. +Run(在新窗口):`D:\otel\start-receiver.cmd` +Run:`cd /d/moss-drobotics && MOSS_OTEL_ENABLED=1 MOSS_OTEL_SERVICE_NAME=moss node packages/moss-agent/dist/cli.js "hello"` +Expected: 面板 `http://localhost:3000` 看到 `moss.session` → `moss.agent.turn` → `moss.llm.request` span 树 + token/duration 指标;`.moss/analytics/traces.jsonl` 有内容。 -- [ ] **Step 4: Verify metrics arrived** - -In the dashboard, confirm metrics: `moss.llm.tokens` (input/output), `moss.llm.request.duration`, `moss.tool.invocations`, `moss.tool.invoke.duration`, `moss.session.count`, `moss.session.duration`, `moss.session.tool_count`. Each with `model`/`tool`/`outcome` dimensions. - -- [ ] **Step 5: Verify local JSONL trace file** - -Run: -```bash -wc -l /d/moss-drobotics/.moss/analytics/traces.jsonl -head -1 /d/moss-drobotics/.moss/analytics/traces.jsonl -``` -Expected: line count > 0; first line is valid JSON with `name`, `startTime`, `endTime`, `attributes`, `status`. - -- [ ] **Step 6: Verify disabled path is truly no-op** +- [ ] **Step 5: Commit** -Run (no env): ```bash cd /d/moss-drobotics -node packages/moss-agent/dist/cli.js +git add packages/moss-agent/test/observability-integration.spec.mjs +git commit -m "test(agent): add observability end-to-end integration spec" ``` -Send one message, exit. Expected: no errors, no `traces.jsonl` created, no requests to `:4318`. - -- [ ] **Step 7: Verify fire-and-forget survives receiver down** -Stop the receiver (Ctrl+C its window). Run moss with `MOSS_OTEL_ENABLED=1`, send a message. Expected: moss runs normally; span sends fail silently (no crash, no hang). Local `traces.jsonl` is still written (FileSpanProcessor doesn't need the receiver). - -- [ ] **Step 8: Run full automated suite one final time** - -Run: `npm test -w @rdk-moss/agent` -Expected: all specs pass. - -- [ ] **Step 9: Final commit (if any verification note files added)** - -If you recorded verification notes, commit them; otherwise no commit needed. The implementation is complete. +--- -```bash -# only if notes were added: -git -C /d/moss-drobotics add -A && git -C /d/moss-drobotics commit -m "docs(observability): verification notes" -``` +## Self-Review 结果 + +**1. Spec 覆盖:** +- 统一 SDK(一处 NodeSDK)→ Task 5 sdk.ts ✅ +- 三层 span(session→turn→llm/tool)→ Task 11 / 10 / 8 / 9 ✅ +- 共享 Resource → Task 5(resourceFromAttributes 一处)✅ +- recordException → Task 4 withSpan catch ✅ +- 批处理 → Task 5 BatchSpanProcessor ✅ +- 本地文件 trace 保留 → Task 3 FileSpanProcessor + Task 5 挂载 ✅ +- tracing 开则 metrics 默认开 → Task 5 index.ts ✅ +- 优雅 flush → Task 7 shutdown + beforeExit ✅ +- 关闭即零开销 → Task 4/2/5 noop + Task 5 spec 验证 ✅ +- web 注入 traceparent → Task 6 ✅ +- metrics 命名纠正(tool_count 非 turns)→ Task 2 ✅ +- 砍 ab-testing / 不搬 trace-exporter 旧实现 → 文件结构未含二者 ✅ +- 依赖 → Task 1 ✅ +- 验证 → Task 12 ✅ + +**2. Placeholder 扫描:** Task 9/10/11 含「按文件实际类型/变量名修正」的指引——这是因 drobotics 代码具体类型(`ExecuteToolCallOutcome.outcome`、`ChatResult.toolCalls`、`chat()` 内 runId 可见性)需在实现时核对,已给出核对位置与不改控制流的约束,非空 TODO。其余步骤均有完整代码。 + +**3. Type consistency:** `withSpan` 签名(Task 4)`withSpan(name, attrs, fn: (span: Span & TraceSpan) => Promise)` 被 Task 8/9/10/11 一致使用;`mossMetrics` 字段名(Task 2:`llmTokens`/`llmDuration`/`toolInvocations`/`toolDuration`/`sessionCount`/`sessionDuration`/`sessionToolCount`)与 Task 8/9/11 调用一致;`propagateHeaders`(Task 5 导出)与 Task 6 调用一致;`readTraceStats`(Task 3 导出)与 Task 12 调用一致。 From 049599b8f97a0e577a81eaaf939e075184219c8c Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 16:57:07 +0800 Subject: [PATCH 04/21] chore(agent): add @opentelemetry/* dependencies for instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 8 OpenTelemetry packages to @rdk-moss/agent: - stable: api@1.9, resources@2.9, sdk-metrics@2.9, sdk-trace-base@2.9, semantic-conventions@1.43 - experimental (aligned at 0.220): sdk-node, exporter-trace-otlp-http, exporter-metrics-otlp-http Note: sdk-trace-base has graduated to stable (2.9.0), not experimental — the plan's ^0.200 assumption was corrected to the real published versions. No code imports the deps yet; build + 80 spec files still pass. Co-Authored-By: Claude --- package-lock.json | 813 ++++++++++++++++++++++++++++++- packages/moss-agent/package.json | 8 + 2 files changed, 817 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c7e55c5f..9a7d7bbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -171,6 +171,118 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@grpc/proto-loader/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@grpc/proto-loader/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -233,6 +345,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", @@ -277,6 +399,590 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/configuration": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/configuration/-/configuration-0.220.0.tgz", + "integrity": "sha512-glfIVKnZevRin8fY/9uES/mhRtMT1lGINLHc9MIo5fTQZXswEEHamJtgjv4MTtzgnhHGC92mIS/0lzAUZMyE0w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "yaml": "^2.8.3" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz", + "integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.220.0.tgz", + "integrity": "sha512-s0sRPCSlXYqlgObOpCftomJllp3LfUL9FobQ5csg2172ydVhSEnu1ptpsVBJadazs5nUNp7vDuLE03FAFWTLOQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/sdk-logs": "0.220.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.220.0.tgz", + "integrity": "sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/sdk-logs": "0.220.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.220.0.tgz", + "integrity": "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/sdk-logs": "0.220.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.220.0.tgz", + "integrity": "sha512-U128izvJfX/dW9jRGP0gIfadR1Hg7ft3UEGIeRxLFK70m2BWw6AtNCOnsUygpw2zCgR/ygdWbGpcL6TmhW0ZGw==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-metrics": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.220.0.tgz", + "integrity": "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-metrics": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.220.0.tgz", + "integrity": "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-metrics": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.220.0.tgz", + "integrity": "sha512-JZD5DL/NBpVd2BHefvYosm3G40UZ/KzExLv5tc0eZe0CtrsHHtcOk3YPUxR2EINmUeBf8+w5UReTV8fFPn95lA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-metrics": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.220.0.tgz", + "integrity": "sha512-bv1xmNhmNwIM6MdUBw4yYuJeVcEViVLk3uD69vOQMwueHBnfyl/u0HnBlB1FNY/Te0UOzJzvcbyR8wN6b+iGbA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/sdk-trace": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.220.0.tgz", + "integrity": "sha512-/+ExB3lRkf+erv4PnoywyL7RHKITidxtUpUTS55k7OQ0dB42S7gEF1gry7swb9MSm1hYLUhJg4QQh9W8SpwwqA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.220.0.tgz", + "integrity": "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.9.0.tgz", + "integrity": "sha512-RwINoce2BH8T4obT5pMcAla2sWma1YZvYuaktWmTluQ0PkQdvv5D060rWI1+kawX+J2qBRcMbwrZJJNcMJUauQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.220.0.tgz", + "integrity": "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-transformer": "0.220.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.220.0.tgz", + "integrity": "sha512-/eIkBPMBTIvM3x/0mDX4aJeSkYifYClnBPr68PL1h5LV4VQv4+SV6CGrpiZ4fIWDnobVmhTWCm1J/QRdAWUfvA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.220.0.tgz", + "integrity": "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-logs": "0.220.0", + "@opentelemetry/sdk-metrics": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/propagator-b3": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-2.9.0.tgz", + "integrity": "sha512-WrOT1WsOUG+B7hstD2RYoMPIOK76G8E9AQHhMjUvrQaGx/oA7rPWQvvr1Rqv7+yy4R0ZMVwWLC4vW2xnkgWPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.9.0.tgz", + "integrity": "sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.220.0.tgz", + "integrity": "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.9.0.tgz", + "integrity": "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-node": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.220.0.tgz", + "integrity": "sha512-wHtGyHhSKHNH3fym33xRu4Ef/HXTFvX8eQ42xdQdEO9LYx9Y2qNyBDJytyqVlvmo6abWZlNYTUthuAGUMYqYnQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/configuration": "0.220.0", + "@opentelemetry/context-async-hooks": "2.9.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/exporter-logs-otlp-grpc": "0.220.0", + "@opentelemetry/exporter-logs-otlp-http": "0.220.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.220.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "0.220.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", + "@opentelemetry/exporter-metrics-otlp-proto": "0.220.0", + "@opentelemetry/exporter-prometheus": "0.220.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "0.220.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.220.0", + "@opentelemetry/exporter-zipkin": "2.9.0", + "@opentelemetry/instrumentation": "0.220.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.220.0", + "@opentelemetry/propagator-b3": "2.9.0", + "@opentelemetry/propagator-jaeger": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-logs": "0.220.0", + "@opentelemetry/sdk-metrics": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0", + "@opentelemetry/sdk-trace-node": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.9.0.tgz", + "integrity": "sha512-ec9a7ps37huy5itYk0MalaZdSLlM6AXWp/FhtEjgMpp5leEGojBDvAl/UWttQnkMZOvFHKzRESn8TD3yKTF5nQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.9.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@rdk-moss/agent": { "resolved": "packages/moss-agent", "link": true @@ -338,7 +1044,6 @@ "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -743,6 +1448,12 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, "node_modules/cli-boxes": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", @@ -988,7 +1699,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1065,6 +1775,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.47.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", @@ -1614,6 +2330,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.1.tgz", + "integrity": "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -1895,6 +2625,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -1902,6 +2638,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/marked": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", @@ -2010,11 +2752,16 @@ "node": ">=18.0.0" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -2261,6 +3008,29 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2447,6 +3217,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2889,7 +3672,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -3051,6 +3833,21 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -3260,6 +4057,14 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-node": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@opentelemetry/semantic-conventions": "^1.43.0", "@rdk-moss/core": "^0.5.3", "highlight.js": "^10.7.3", "ink": "^7.0.5", diff --git a/packages/moss-agent/package.json b/packages/moss-agent/package.json index 3135511c..5b93a8ac 100644 --- a/packages/moss-agent/package.json +++ b/packages/moss-agent/package.json @@ -172,6 +172,14 @@ "docs": "typedoc --out docs-api src/index.ts src/core/index.ts src/knowledge/index.ts --tsconfig tsconfig.build.json" }, "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-node": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@opentelemetry/semantic-conventions": "^1.43.0", "@rdk-moss/core": "^0.5.3", "ink": "^7.0.5", "marked": "^5.1.0", From a5fcbfcf7da04dca2cd7b964a22df6bf90717562 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 17:01:23 +0800 Subject: [PATCH 05/21] feat(agent): add mossMetrics OTel instrument handles metrics.ts exposes a noop-by-default instrument handle (llmTokens, llmDuration, toolInvocations, toolDuration, sessionCount, sessionDuration, sessionToolCount). Before setGlobalMeterProvider, metrics.getMeter() returns a noop meter, so business call-sites invoke .add()/.record() unconditionally at zero cost. sessionToolCount corrects the from-remote misnaming of per-turn tool count as "session.turns". Re-exported from observability/index.ts. Co-Authored-By: Claude --- .../moss-agent/src/observability/index.ts | 1 + .../moss-agent/src/observability/metrics.ts | 28 +++++++++++++++++ .../test/observability-metrics.spec.mjs | 30 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 packages/moss-agent/src/observability/metrics.ts create mode 100644 packages/moss-agent/test/observability-metrics.spec.mjs diff --git a/packages/moss-agent/src/observability/index.ts b/packages/moss-agent/src/observability/index.ts index 80ef611f..729ea105 100644 --- a/packages/moss-agent/src/observability/index.ts +++ b/packages/moss-agent/src/observability/index.ts @@ -10,6 +10,7 @@ export { llmRequestAttributes, } from './tracing.js'; export type { Tracer, TraceSpan } from './tracing.js'; +export { mossMetrics } from './metrics.js'; export { logLLMUsage, readUsageLog, diff --git a/packages/moss-agent/src/observability/metrics.ts b/packages/moss-agent/src/observability/metrics.ts new file mode 100644 index 00000000..a27fea50 --- /dev/null +++ b/packages/moss-agent/src/observability/metrics.ts @@ -0,0 +1,28 @@ +/** + * OpenTelemetry metrics for Moss — instrument handles. + * + * Uses the global MeterProvider. When none is registered (observability + * disabled), metrics.getMeter() returns a noop meter whose instruments are + * no-ops, so business code calls .add()/.record() unconditionally at zero cost. + * + * Usage: + * import { mossMetrics } from './observability/index.js'; + * mossMetrics.llmTokens.add(inputTokens, { direction: 'input', model }); + */ +import { metrics } from '@opentelemetry/api'; + +const meter = metrics.getMeter('moss-agent'); + +export const mossMetrics = { + // LLM + llmTokens: meter.createCounter('moss.llm.tokens', { unit: '{token}' }), + llmDuration: meter.createHistogram('moss.llm.request.duration', { unit: 'ms' }), + // tool + toolInvocations: meter.createCounter('moss.tool.invocations'), + toolDuration: meter.createHistogram('moss.tool.invoke.duration', { unit: 'ms' }), + // session + sessionCount: meter.createCounter('moss.session.count'), + sessionDuration: meter.createHistogram('moss.session.duration', { unit: 'ms' }), + // 每轮工具数(纠正 from-remote 把它误命名为 session.turns 的错位) + sessionToolCount: meter.createHistogram('moss.session.tool_count'), +}; diff --git a/packages/moss-agent/test/observability-metrics.spec.mjs b/packages/moss-agent/test/observability-metrics.spec.mjs new file mode 100644 index 00000000..658a11f0 --- /dev/null +++ b/packages/moss-agent/test/observability-metrics.spec.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +// @rdk-moss/agent — mossMetrics instruments export + noop behavior (no provider registered). +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'metrics.js')).href); +const { mossMetrics } = mod; + +// 未 setGlobalMeterProvider 时返回 noop meter,instruments 仍可调用不抛错。 +assert.ok(mossMetrics, 'mossMetrics should be exported'); +assert.equal(typeof mossMetrics.llmTokens.add, 'function', 'llmTokens.add is a function'); +assert.equal(typeof mossMetrics.llmDuration.record, 'function', 'llmDuration.record is a function'); +assert.equal(typeof mossMetrics.toolInvocations.add, 'function', 'toolInvocations.add is a function'); +assert.equal(typeof mossMetrics.toolDuration.record, 'function', 'toolDuration.record is a function'); +assert.equal(typeof mossMetrics.sessionCount.add, 'function', 'sessionCount.add is a function'); +assert.equal(typeof mossMetrics.sessionDuration.record, 'function', 'sessionDuration.record is a function'); +assert.equal(typeof mossMetrics.sessionToolCount.record, 'function', 'sessionToolCount.record is a function'); + +// noop 调用零成本、不抛 +assert.doesNotThrow(() => { + mossMetrics.llmTokens.add(10, { direction: 'input', model: 'm' }); + mossMetrics.llmDuration.record(123, { model: 'm' }); + mossMetrics.toolInvocations.add(1, { tool: 't', status: 'ok' }); + mossMetrics.sessionCount.add(1, { outcome: 'ok' }); + mossMetrics.sessionToolCount.record(3, { outcome: 'ok' }); +}); + +console.error('[spec] observability-metrics OK'); From 1cbac14503a42c76ead918a018c616525d9ec685 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 17:08:14 +0800 Subject: [PATCH 06/21] feat(agent): add FileSpanProcessor for local JSONL trace export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileSpanProcessor implements the OTel SpanProcessor interface, buffering ended ReadableSpans and flushing them to {workspaceDir}/.moss/analytics/traces.jsonl every 30s and on shutdown. serializeSpan maps SDK HrTime ([sec, ns]) to epoch-ms and SpanStatusCode to ok/error. readTraceStats aggregates the JSONL for CLI reporting. Verified against the installed sdk-trace-base@2.9.0 (ReadableSpan, SpanProcessor, TimedEvent shapes) — the spec's BigInt HrTime was corrected to the real [number, number] shape. Co-Authored-By: Claude --- .../src/observability/file-trace.ts | 140 ++++++++++++++++++ .../moss-agent/src/observability/index.ts | 2 + .../test/observability-file-trace.spec.mjs | 61 ++++++++ 3 files changed, 203 insertions(+) create mode 100644 packages/moss-agent/src/observability/file-trace.ts create mode 100644 packages/moss-agent/test/observability-file-trace.spec.mjs diff --git a/packages/moss-agent/src/observability/file-trace.ts b/packages/moss-agent/src/observability/file-trace.ts new file mode 100644 index 00000000..9a60b614 --- /dev/null +++ b/packages/moss-agent/src/observability/file-trace.ts @@ -0,0 +1,140 @@ +/** + * FileSpanProcessor — serializes SDK ReadableSpans to a local JSONL file. + * + * Attached to the TracerProvider alongside the OTLP exporter, so the same + * spans ship to the receiver AND land on disk. Best-effort: flush failures + * never block the agent. + * + * Path: {workspaceDir}/.moss/analytics/traces.jsonl + */ +import type { SpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { Context, HrTime } from '@opentelemetry/api'; +import { SpanStatusCode } from '@opentelemetry/api'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const FLUSH_INTERVAL_MS = 30_000; + +export interface SerializedSpan { + name: string; + startTime: number; + endTime: number; + attributes: Record; + events: Array<{ name: string; time: number; attrs?: Record }>; + status: 'ok' | 'error'; + statusMessage?: string; +} + +export interface TraceStats { + totalSpans: number; + totalErrors: number; + errorRate: number; + byName: Record; + toolSpans: Array<{ toolName: string; count: number; errors: number; avgDurationMs: number }>; +} + +/** HrTime is [seconds, nanoseconds]; reduce to epoch-ms number for the file. */ +function hrToMs(hr: HrTime): number { + return hr[0] * 1000 + Math.trunc(hr[1] / 1_000_000); +} + +/** Convert an SDK ReadableSpan into the JSONL-friendly SerializedSpan shape. */ +export function serializeSpan(span: ReadableSpan): SerializedSpan { + const attrs: Record = {}; + for (const [k, v] of Object.entries(span.attributes ?? {})) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') attrs[k] = v; + } + return { + name: span.name, + startTime: hrToMs(span.startTime), + endTime: hrToMs(span.endTime), + attributes: attrs, + events: (span.events ?? []).map((e) => ({ + name: e.name, + time: hrToMs(e.time), + ...(e.attributes ? { attrs: e.attributes as Record } : {}), + })), + status: span.status.code === SpanStatusCode.ERROR ? 'error' : 'ok', + ...(span.status.code === SpanStatusCode.ERROR && span.status.message + ? { statusMessage: span.status.message } + : {}), + }; +} + +export class FileSpanProcessor implements SpanProcessor { + private buffer: ReadableSpan[] = []; + private timer: ReturnType | null = null; + private readonly file: string; + + constructor(workspaceDir: string) { + this.file = path.join(workspaceDir, '.moss', 'analytics', 'traces.jsonl'); + this.timer = setInterval(() => { void this.flush(); }, FLUSH_INTERVAL_MS); + } + + onStart(_span: ReadableSpan, _parentContext: Context): void { + /* nothing — we serialize on end */ + } + + onEnd(span: ReadableSpan): void { + this.buffer.push(span); + } + + async flush(): Promise { + if (this.buffer.length === 0) return; + const snapshot = this.buffer.splice(0); + const lines = snapshot.map(serializeSpan).map((s) => JSON.stringify(s)).join('\n') + '\n'; + try { + await fs.mkdir(path.dirname(this.file), { recursive: true }); + await fs.appendFile(this.file, lines, 'utf-8'); + } catch { + // Silently ignore — never block the agent. + } + } + + async forceFlush(): Promise { + await this.flush(); + } + + async shutdown(): Promise { + if (this.timer) clearInterval(this.timer); + this.timer = null; + await this.flush(); + } +} + +function emptyStats(): TraceStats { + return { totalSpans: 0, totalErrors: 0, errorRate: 0, byName: {}, toolSpans: [] }; +} + +/** Read aggregated stats from a traces.jsonl file (for CLI reporting). */ +export async function readTraceStats(file: string): Promise { + let lines: string[]; + try { + lines = (await fs.readFile(file, 'utf-8')).split('\n').filter(Boolean); + } catch { + return emptyStats(); + } + const stats = emptyStats(); + for (const line of lines) { + let span: SerializedSpan; + try { span = JSON.parse(line); } catch { continue; } + stats.totalSpans++; + if (span.status === 'error') stats.totalErrors++; + const entry = stats.byName[span.name] ??= { count: 0, errors: 0, avgDurationMs: 0 }; + entry.count++; + if (span.status === 'error') entry.errors++; + const duration = span.endTime - span.startTime; + entry.avgDurationMs = (entry.avgDurationMs * (entry.count - 1) + duration) / entry.count; + if (span.name === 'moss.tool.invoke') { + const toolName = String(span.attributes.toolName ?? 'unknown'); + let tool = stats.toolSpans.find((t) => t.toolName === toolName); + if (!tool) { tool = { toolName, count: 0, errors: 0, avgDurationMs: 0 }; stats.toolSpans.push(tool); } + tool.count++; + if (span.status === 'error') tool.errors++; + tool.avgDurationMs = (tool.avgDurationMs * (tool.count - 1) + duration) / tool.count; + } + } + stats.errorRate = stats.totalSpans > 0 ? stats.totalErrors / stats.totalSpans : 0; + stats.toolSpans.sort((a, b) => b.count - a.count); + return stats; +} diff --git a/packages/moss-agent/src/observability/index.ts b/packages/moss-agent/src/observability/index.ts index 729ea105..7aa02227 100644 --- a/packages/moss-agent/src/observability/index.ts +++ b/packages/moss-agent/src/observability/index.ts @@ -11,6 +11,8 @@ export { } from './tracing.js'; export type { Tracer, TraceSpan } from './tracing.js'; export { mossMetrics } from './metrics.js'; +export { FileSpanProcessor, readTraceStats } from './file-trace.js'; +export type { SerializedSpan, TraceStats } from './file-trace.js'; export { logLLMUsage, readUsageLog, diff --git a/packages/moss-agent/test/observability-file-trace.spec.mjs b/packages/moss-agent/test/observability-file-trace.spec.mjs new file mode 100644 index 00000000..603fc99f --- /dev/null +++ b/packages/moss-agent/test/observability-file-trace.spec.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// @rdk-moss/agent — FileSpanProcessor buffers spans, flushes to traces.jsonl, +// and readTraceStats aggregates. Uses minimal ReadableSpan-shaped objects. +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'file-trace.js')).href); +const { FileSpanProcessor, readTraceStats } = mod; + +// 构造一个最小 ReadableSpan 形状 (SDK 的 ReadableSpan 是接口,鸭子类型即可) +function fakeSpan(name, attrs, isError) { + const now = Date.now(); + const sec = Math.trunc(now / 1000); + return { + name, + kind: 0, + spanContext: () => ({ traceId: 't'.repeat(32), spanId: 's'.repeat(16) }), + startTime: [sec, 0], + endTime: [sec, 1_000_000], + attributes: attrs ?? {}, + status: isError ? { code: 2, message: 'boom' } : { code: 1 }, + events: [], + resource: { attributes: [] }, + instrumentationScope: { name: 'moss-agent' }, + duration: [0, 1_000_000], + ended: true, + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + links: [], + }; +} + +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-trace-')); +const proc = new FileSpanProcessor(tmp); +proc.onStart(fakeSpan('noop'), undefined); +proc.onEnd(fakeSpan('moss.tool.invoke', { toolName: 'read_file' }, false)); +proc.onEnd(fakeSpan('moss.tool.invoke', { toolName: 'read_file' }, true)); +proc.onEnd(fakeSpan('moss.llm.request', { model: 'm' }, false)); +await proc.forceFlush(); + +const file = path.join(tmp, '.moss', 'analytics', 'traces.jsonl'); +const content = await fs.readFile(file, 'utf8'); +const lines = content.split('\n').filter(Boolean); +assert.equal(lines.length, 3, 'should write 3 span lines'); + +const stats = await readTraceStats(file); +assert.equal(stats.totalSpans, 3, 'totalSpans'); +assert.equal(stats.totalErrors, 1, 'totalErrors'); +assert.ok(stats.byName['moss.tool.invoke'], 'tool span aggregated by name'); +assert.equal(stats.byName['moss.tool.invoke'].count, 2, '2 tool spans'); +assert.equal(stats.byName['moss.tool.invoke'].errors, 1, '1 tool error'); +assert.equal(stats.toolSpans[0].toolName, 'read_file', 'tool breakdown by toolName'); + +await proc.shutdown(); +await fs.rm(tmp, { recursive: true, force: true }); +console.error('[spec] observability-file-trace OK'); From dcf190fee081db53cb652865dfd38da434fb8799 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 17:15:42 +0800 Subject: [PATCH 07/21] feat(agent): rewrite tracing.ts onto OTel SDK withSpan withSpan now uses trace.getTracer() + context propagation; on error it records the exception as a span event and sets ERROR with a redacted message (String-coerced because redactSensitiveData returns unknown). The fn callback receives the real OTel Span. Legacy setTracer/setTraceRedactor/TraceRegistry/getTracer are retained as noop shims so cli-main.ts, moss-agent.ts, and agent-loop-llm-call.ts imports keep resolving until callers migrate to initObservability. Added sessionAttributes builder for the session-span task. Co-Authored-By: Claude --- .../moss-agent/src/observability/index.ts | 2 + .../moss-agent/src/observability/tracing.ts | 229 +++++++----------- .../test/observability-tracing.spec.mjs | 44 ++++ 3 files changed, 136 insertions(+), 139 deletions(-) create mode 100644 packages/moss-agent/test/observability-tracing.spec.mjs diff --git a/packages/moss-agent/src/observability/index.ts b/packages/moss-agent/src/observability/index.ts index 7aa02227..df84ae79 100644 --- a/packages/moss-agent/src/observability/index.ts +++ b/packages/moss-agent/src/observability/index.ts @@ -3,11 +3,13 @@ export type { RedactOptions } from './redact.js'; export { TraceRegistry, setTracer, + setTraceRedactor, getTracer, withSpan, turnAttributes, toolAttributes, llmRequestAttributes, + sessionAttributes, } from './tracing.js'; export type { Tracer, TraceSpan } from './tracing.js'; export { mossMetrics } from './metrics.js'; diff --git a/packages/moss-agent/src/observability/tracing.ts b/packages/moss-agent/src/observability/tracing.ts index 71337e10..7cb43efb 100644 --- a/packages/moss-agent/src/observability/tracing.ts +++ b/packages/moss-agent/src/observability/tracing.ts @@ -1,183 +1,134 @@ +/** + * Tracing — SDK-backed withSpan + attributes. + * + * Uses the global TracerProvider. When none is registered (observability + * disabled), trace.getTracer() returns a noop tracer and withSpan runs fn + * directly with zero tracing overhead. + * + * Legacy setTracer/setTraceRedactor/TraceRegistry/getTracer are kept as noop + * shims so existing imports (cli-main.ts, moss-agent.ts, agent-loop-llm-call.ts) + * do not break until callers are migrated to initObservability. + */ +import { trace, context, SpanStatusCode } from '@opentelemetry/api'; +import type { Span } from '@opentelemetry/api'; import { errorMessage } from '../errors.js'; +import { redactSensitiveData } from './redact.js'; +const tracer = trace.getTracer('moss-agent'); - - - - - - - - - - +/** Public span handle passed to withSpan's fn (mirrors the OTel Span API). */ export interface TraceSpan { - setAttribute(key: string, value: string | number | boolean): void; - addEvent(name: string, attributes?: Record): void; - setStatus(ok: boolean, message?: string): void; - end(): void; } +/** Legacy Tracer interface — kept for typing compatibility. */ export interface Tracer { - startSpan( name: string, attributes?: Record, - parent?: TraceSpan + parent?: TraceSpan, ): TraceSpan; } - - -const noopSpan: TraceSpan = { - setAttribute() {}, - addEvent() {}, - setStatus() {}, - end() {}, -}; - -const noopTracer: Tracer = { - startSpan(_name, _attrs, _parent) { - return noopSpan; - }, -}; - - - -function createConsoleTracer(): Tracer { - return { - startSpan(name, attributes, parent) { - const start = Date.now(); - const attrs = attributes ? ` ${JSON.stringify(attributes)}` : ''; - const parentInfo = parent ? ` (parent)` : ''; - console.error(`[trace] ▶ ${name}${attrs}${parentInfo}`); - return { - setAttribute(key, value) { - console.error(`[trace] ${name}.${key} = ${value}`); - }, - addEvent(eventName, eventAttrs) { - const ea = eventAttrs ? ` ${JSON.stringify(eventAttrs)}` : ''; - console.error(`[trace] ${name} :: ${eventName}${ea}`); - }, - setStatus(ok, message) { - const status = ok ? 'OK' : 'ERROR'; - const msg = message ? ` (${message})` : ''; - console.error(`[trace] ${name} status=${status}${msg}`); - }, - end() { - const ms = Date.now() - start; - console.error(`[trace] ◀ ${name} (${ms}ms)`); - }, - }; - }, - }; -} - - - -export class TraceRegistry { - private tracer: Tracer = noopTracer; - private redactor: ((text: string) => string) | null = null; - - setTracer(tracer: Tracer | 'console'): void { - this.tracer = tracer === 'console' ? createConsoleTracer() : tracer; - } - - setTraceRedactor(fn: (text: string) => string): void { - this.redactor = fn; - } - - getTracer(): Tracer { - return this.tracer; - } - - redactMessage(text: string): string { - return this.redactor ? this.redactor(text) : text; - } -} - -const defaultTraceRegistry = new TraceRegistry(); - - - - - -export function setTracer(tracer: Tracer | 'console'): void { - defaultTraceRegistry.setTracer(tracer); -} - - - - - - -export function setTraceRedactor(fn: (text: string) => string): void { - defaultTraceRegistry.setTraceRedactor(fn); -} - - -export function getTracer(): Tracer { - return defaultTraceRegistry.getTracer(); -} - - - - - +/** + * Run fn inside a span. On success sets OK; on throw records the exception + * (type/message/stack as a span event), sets ERROR with a redacted message, + * and rethrows. Never swallows errors. + */ export async function withSpan( name: string, attributes: Record | undefined, - fn: (span: TraceSpan) => Promise, - parent?: TraceSpan + fn: (span: Span) => Promise, ): Promise { - const span = defaultTraceRegistry.getTracer().startSpan(name, attributes, parent); - try { - const result = await fn(span); - span.setStatus(true); - return result; - } catch (err) { - span.setStatus(false, defaultTraceRegistry.redactMessage(errorMessage(err))); - throw err; - } finally { - span.end(); - } + const span = tracer.startSpan(name, attributes ? { attributes } : undefined); + return context.with(trace.setSpan(context.active(), span), async () => { + try { + const result = await fn(span); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (err) { + span.recordException(err as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: String(redactSensitiveData(errorMessage(err))), + }); + throw err; + } finally { + span.end(); + } + }); } - - - - +// ── Attributes constructors (span dimensions, centralized) ────────────── export function turnAttributes( runId: string, turn: number, - model: string + model: string, ): Record { return { runId, turn, model }; } - - - export function toolAttributes( runId: string, toolName: string, - toolCallId: string + toolCallId: string, ): Record { return { runId, toolName, toolCallId }; } - - - export function llmRequestAttributes( runId: string, model: string, - inputTokens: number + inputTokens: number, ): Record { return { runId, model, inputTokens }; } + +export function sessionAttributes( + runId: string, + model: string, + sessionKey: string, +): Record { + return { runId, model, sessionKey }; +} + +// ── Legacy noop shims (do not remove — existing imports depend on them) ── + +const noopSpan: TraceSpan = { + setAttribute() {}, + addEvent() {}, + setStatus() {}, + end() {}, +}; + +const noopTracer: Tracer = { + startSpan() { return noopSpan; }, +}; + +export class TraceRegistry { + setTracer(_tracer: Tracer | 'console'): void { + /* no-op under the SDK model */ + } + setTraceRedactor(_fn: (text: string) => string): void { + /* no-op — redaction handled by redactSensitiveData in withSpan */ + } + getTracer(): Tracer { return noopTracer; } + redactMessage(text: string): string { return text; } +} + +export function setTracer(_tracer: Tracer | 'console'): void { + // No-op under the SDK model. Tracer/MeterProvider is configured via + // initObservability() in observability/index.ts. +} + +export function setTraceRedactor(_fn: (text: string) => string): void { + /* no-op under the SDK model */ +} + +export function getTracer(): Tracer { + return noopTracer; +} diff --git a/packages/moss-agent/test/observability-tracing.spec.mjs b/packages/moss-agent/test/observability-tracing.spec.mjs new file mode 100644 index 00000000..855609e8 --- /dev/null +++ b/packages/moss-agent/test/observability-tracing.spec.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// @rdk-moss/agent — withSpan: success path sets OK, error path records +// exception + rethrows. attributes constructors produce expected shape. +// setTracer/setTraceRedactor/TraceRegistry/getTracer legacy shims exist & are noop. +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'tracing.js')).href); +const { + withSpan, turnAttributes, toolAttributes, llmRequestAttributes, + setTracer, setTraceRedactor, getTracer, TraceRegistry, +} = mod; + +// 未注册 tracer provider 时为 noop tracer,withSpan 仍正常执行 fn 并返回结果。 +const result = await withSpan('test.span', { a: 1 }, async (span) => { + assert.equal(typeof span.setAttribute, 'function'); + assert.equal(typeof span.addEvent, 'function'); + assert.equal(typeof span.setStatus, 'function'); + assert.equal(typeof span.end, 'function'); + span.setAttribute('k', 'v'); + span.addEvent('ev', { x: 1 }); + return 42; +}); +assert.equal(result, 42, 'withSpan returns fn result'); + +// 异常路径:rethrow(不吞错) +await assert.rejects( + withSpan('test.err', {}, async () => { throw new Error('boom'); }), + /boom/, +); + +// attributes 构造器形状 +assert.deepEqual(turnAttributes('r1', 3, 'm'), { runId: 'r1', turn: 3, model: 'm' }); +assert.deepEqual(toolAttributes('r1', 'read_file', 'tc1'), { runId: 'r1', toolName: 'read_file', toolCallId: 'tc1' }); +assert.deepEqual(llmRequestAttributes('r1', 'm', 100), { runId: 'r1', model: 'm', inputTokens: 100 }); + +// 旧 API 保留为 noop shim,不抛 +assert.doesNotThrow(() => setTracer('console')); +assert.doesNotThrow(() => setTraceRedactor((s) => s)); +assert.ok(getTracer()); +assert.ok(new TraceRegistry()); +console.error('[spec] observability-tracing OK'); From a2a653b25ef1dac7e45bc59dc0a9dfe564f81d6f Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 17:24:20 +0800 Subject: [PATCH 08/21] feat(agent): add sdk.ts assembly + observability public entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdk.ts assembles a single NodeSDK: one shared Resource (service.name + service.version from package.json), BatchSpanProcessor→OTLP trace exporter, FileSpanProcessor for local JSONL, and a PeriodicExportingMetricReader→ OTLP metric exporter. Uses metricReaders (plural) — metricReader is deprecated. Best-effort init: never crashes the agent. index.ts now exports initObservability (env-gated noop when disabled), shutdownObservability, propagateHeaders (W3C traceparent via the SDK propagation API + defaultTextMapSetter). tracing-on ⇒ metrics-on by default; local file trace on by default. Co-Authored-By: Claude --- .../moss-agent/src/observability/index.ts | 50 ++++++++++++ packages/moss-agent/src/observability/sdk.ts | 79 +++++++++++++++++++ .../test/observability-noop.spec.mjs | 27 +++++++ 3 files changed, 156 insertions(+) create mode 100644 packages/moss-agent/src/observability/sdk.ts create mode 100644 packages/moss-agent/test/observability-noop.spec.mjs diff --git a/packages/moss-agent/src/observability/index.ts b/packages/moss-agent/src/observability/index.ts index df84ae79..6e1c18bd 100644 --- a/packages/moss-agent/src/observability/index.ts +++ b/packages/moss-agent/src/observability/index.ts @@ -1,3 +1,11 @@ +/** + * Observability public entrypoint. + * + * initObservability() wires up the OTel SDK (trace + metric + local file + * trace) based on env vars; when disabled it is a no-op and withSpan / + * mossMetrics.* run as no-ops. Call once at CLI startup (before agent + * creation); call shutdownObservability() on exit to flush. + */ export { redactSensitiveData, parseTelemetryAllow } from './redact.js'; export type { RedactOptions } from './redact.js'; export { @@ -24,3 +32,45 @@ export { registerModelPricing, } from './llm-usage.js'; export type { LLMUsageRecord, LLMUsageSummary } from './llm-usage.js'; + +import { initObservabilitySdk } from './sdk.js'; +import { propagation, context as otelContext, defaultTextMapSetter } from '@opentelemetry/api'; + +export interface InitOptions { + workspaceDir: string; + serviceName?: string; + otlpUrl?: string; +} + +export function initObservability(opts: InitOptions): void { + const enabled = process.env.MOSS_OTEL_ENABLED === '1' || !!process.env.MOSS_OTEL_URL; + if (!enabled) return; + initObservabilitySdk({ + serviceName: process.env.MOSS_OTEL_SERVICE_NAME ?? opts.serviceName ?? 'moss', + otlpUrl: process.env.MOSS_OTEL_URL ?? opts.otlpUrl ?? 'http://localhost:4318', + enabled: true, + // tracing 开则 metrics 默认开(纠正 from-remote 两开关易漏配) + metricsEnabled: process.env.MOSS_METRICS_ENABLED !== '0', + // 本地文件 trace 默认开,MOSS_FILE_TRACE=0 关 + fileTraceEnabled: process.env.MOSS_FILE_TRACE !== '0', + workspaceDir: opts.workspaceDir, + }); +} + +export { shutdownObservabilitySdk as shutdownObservability } from './sdk.js'; + +/** + * Inject W3C traceparent into outbound fetch headers for the current span. + * Returns headers unchanged when no active span (graceful degradation). + */ +export function propagateHeaders( + headers: Record = {}, +): Record { + try { + const injected: Record = { ...headers }; + propagation.inject(otelContext.active(), injected, defaultTextMapSetter); + return injected; + } catch { + return headers; + } +} diff --git a/packages/moss-agent/src/observability/sdk.ts b/packages/moss-agent/src/observability/sdk.ts new file mode 100644 index 00000000..68c6934d --- /dev/null +++ b/packages/moss-agent/src/observability/sdk.ts @@ -0,0 +1,79 @@ +/** + * OpenTelemetry SDK assembly — single NodeSDK for trace + metric, shared Resource. + * Local file trace attaches as a FileSpanProcessor alongside the OTLP exporter. + * + * initObservabilitySdk reads env to decide what to start. When disabled it + * does nothing (no SDK, no timers). shutdownObservabilitySdk flushes all + * exporters/processors (fire-and-forget send errors never crash the agent). + */ +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; +import fs from 'node:fs'; +import path from 'node:path'; +import { FileSpanProcessor } from './file-trace.js'; +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; + +export interface ObservabilityConfig { + serviceName: string; + otlpUrl: string; + enabled: boolean; + metricsEnabled: boolean; + fileTraceEnabled: boolean; + workspaceDir: string; +} + +let sdk: NodeSDK | null = null; + +/** Read package version from packages/moss-agent/package.json (not hardcoded). */ +function readPackageVersion(): string { + try { + const pkgPath = path.join(import.meta.dirname, '..', '..', 'package.json'); + return String(JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version ?? '0.0.0'); + } catch { + return '0.0.0'; + } +} + +export function initObservabilitySdk(cfg: ObservabilityConfig): void { + if (!cfg.enabled || sdk) return; + try { + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: cfg.serviceName, + [ATTR_SERVICE_VERSION]: readPackageVersion(), + }); + + const spanProcessors: SpanProcessor[] = [ + new BatchSpanProcessor(new OTLPTraceExporter({ url: `${cfg.otlpUrl}/v1/traces` })), + ]; + if (cfg.fileTraceEnabled) { + spanProcessors.push(new FileSpanProcessor(cfg.workspaceDir)); + } + + const metricReaders = cfg.metricsEnabled + ? [new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${cfg.otlpUrl}/v1/metrics` }), + exportIntervalMillis: 10_000, + })] + : []; + + sdk = new NodeSDK({ + resource, + spanProcessors, + ...(metricReaders.length > 0 ? { metricReaders } : {}), + }); + sdk.start(); + } catch { + // Best-effort — never block the agent. Failure means no telemetry, not a crash. + } +} + +export async function shutdownObservabilitySdk(): Promise { + if (!sdk) return; + try { await sdk.shutdown(); } catch { /* ignore */ } + sdk = null; +} diff --git a/packages/moss-agent/test/observability-noop.spec.mjs b/packages/moss-agent/test/observability-noop.spec.mjs new file mode 100644 index 00000000..3b54b8cd --- /dev/null +++ b/packages/moss-agent/test/observability-noop.spec.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// @rdk-moss/agent — initObservability is a noop when disabled; +// propagateHeaders passes through when no active span. +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs/promises'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'index.js')).href); +const { initObservability, shutdownObservability, propagateHeaders } = mod; + +// 不设任何 env,initObservability 应 noop(不创建文件、不抛) +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-noop-')); +delete process.env.MOSS_OTEL_ENABLED; +delete process.env.MOSS_OTEL_URL; +assert.doesNotThrow(() => initObservability({ workspaceDir: tmp })); +await shutdownObservability(); + +// propagateHeaders 无 active span 时原样返回(补一个已存在的 header) +const out = propagateHeaders({ 'x-custom': '1' }); +assert.equal(out['x-custom'], '1', 'passes existing headers through'); +assert.ok(out, 'returns a headers object'); + +await fs.rm(tmp, { recursive: true, force: true }); +console.error('[spec] observability-noop OK'); From 772be0316a75da441aa9832d454b778ed393829d Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 17:27:26 +0800 Subject: [PATCH 09/21] feat(tools): inject W3C traceparent into web-fetch/web-search requests Both outbound fetches now route headers through propagateHeaders, which adds a W3C traceparent for the active span (or passes through unchanged when no span is active). Lets the receiver correlate upstream HTTP calls with the agent's trace tree. Co-Authored-By: Claude --- packages/moss-agent/src/tools/web-fetch.ts | 5 +++-- packages/moss-agent/src/tools/web-search.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/moss-agent/src/tools/web-fetch.ts b/packages/moss-agent/src/tools/web-fetch.ts index 1c1d4915..b86c1d37 100644 --- a/packages/moss-agent/src/tools/web-fetch.ts +++ b/packages/moss-agent/src/tools/web-fetch.ts @@ -20,6 +20,7 @@ import TurndownService from 'turndown'; import type { Tool, ToolContext } from '../core/tools/tool-types.js'; import { getRootLogger } from '../logger.js'; import { MossError, ErrorCode, errorMessage } from '../errors.js'; +import { propagateHeaders } from '../observability/index.js'; const log = getRootLogger().child('tool:web-fetch'); @@ -538,12 +539,12 @@ export function createWebFetchTool(opts: WebFetchOptions = {}): Tool<{ url: stri if (pinnedDispatcher) dispatchersToClose.push(pinnedDispatcher); const fetchInit: RequestInit = { signal: mergedSignal, - headers: { + headers: propagateHeaders({ 'User-Agent': activeUserAgent, Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5', ...(shouldRewriteToIp ? { Host: originalHost } : {}), - }, + }), redirect: 'manual', }; if (pinnedDispatcher) { diff --git a/packages/moss-agent/src/tools/web-search.ts b/packages/moss-agent/src/tools/web-search.ts index b6406b5d..aebfcaf3 100644 --- a/packages/moss-agent/src/tools/web-search.ts +++ b/packages/moss-agent/src/tools/web-search.ts @@ -33,6 +33,7 @@ import type { Tool, ToolContext } from '../core/tools/tool-types.js'; import { getRootLogger } from '../logger.js'; import { MossError, ErrorCode , errorMessage} from '../errors.js'; import { createRssSearchBackend, parseUserFeeds } from './rss-search.js'; +import { propagateHeaders } from '../observability/index.js'; const log = getRootLogger().child('tool:web-search'); @@ -230,7 +231,11 @@ async function fetchWithTimeout( outerSignal?.addEventListener('abort', onAbort, { once: true }); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetch(url, { ...init, signal: controller.signal }); + const res = await fetch(url, { + ...init, + headers: propagateHeaders((init?.headers ?? {}) as Record), + signal: controller.signal, + }); const text = await res.text(); return { ok: res.ok, status: res.status, text }; } catch (err) { From 123c7d8908a56512839905de5acfb99f19c4073d Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 17:30:54 +0800 Subject: [PATCH 10/21] feat(cli): wire observability init at startup + flush on exit initObservability runs before MossAgent construction (no-op unless MOSS_OTEL_ENABLED / MOSS_OTEL_URL set). shutdownObservability runs in the main finally block and on beforeExit to flush in-flight traces/metrics. Co-Authored-By: Claude --- packages/moss-agent/src/cli-main.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/moss-agent/src/cli-main.ts b/packages/moss-agent/src/cli-main.ts index 21252138..d00e36a4 100644 --- a/packages/moss-agent/src/cli-main.ts +++ b/packages/moss-agent/src/cli-main.ts @@ -63,6 +63,7 @@ import { AgentMesh, createMeshTools, isMeshVerboseEnabled } from './mesh/agent-m import { MeshEventBus } from './mesh/index.js'; import { LanDiscovery } from './mesh/lan-discovery.js'; import { setTracer } from './observability/tracing.js'; +import { initObservability, shutdownObservability } from './observability/index.js'; import { redactSensitiveData } from './observability/redact.js'; import { resolveCliDetailMode } from './cli/output.js'; import type { DeviceSshConfig } from './tools/device-ssh.js'; @@ -608,6 +609,11 @@ async function main() { }); const cliLlmProvider = parsedArgs.mock ? createMockLLMProvider() : createCliProvider(providerConfig); + + // Initialize observability (OTel tracing + metrics + local file trace) based on env. + // No-op when MOSS_OTEL_ENABLED is unset and MOSS_OTEL_URL absent. + initObservability({ workspaceDir: workspace }); + const agent = new MossAgent({ llmProvider: cliLlmProvider, sessionStore, model, workspaceDir: workspace, @@ -991,9 +997,13 @@ async function main() { await runInteractive(agent, skillLearner, liveRuntime, { sessionKey: session.sessionKey }); } finally { await closeMcpConnections(mcpConnections); + await shutdownObservability(); } } +// Flush any in-flight traces/metrics on unexpected exit paths. +process.on('beforeExit', () => { void shutdownObservability(); }); + main().catch((err) => { // Config file errors already carry a clean, actionable one-liner — show it // alone instead of a raw Node stack. A malformed/hand-edited config.json From f8315ec3a9c2126b21953e75ae1a04b318265937 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 17:33:59 +0800 Subject: [PATCH 11/21] feat(loop): rename LLM span to moss.llm.request + emit LLM metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Span renamed agent.llm_turn → moss.llm.request for naming consistency. Records moss.llm.tokens (input/output, with direction + model) and moss.llm.request.duration on success; records duration with status=error on failure. Noop when metrics disabled. Co-Authored-By: Claude --- .../moss-agent/src/core/loop/agent-loop-llm-call.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/moss-agent/src/core/loop/agent-loop-llm-call.ts b/packages/moss-agent/src/core/loop/agent-loop-llm-call.ts index 2296b6c0..259c25af 100644 --- a/packages/moss-agent/src/core/loop/agent-loop-llm-call.ts +++ b/packages/moss-agent/src/core/loop/agent-loop-llm-call.ts @@ -20,6 +20,7 @@ import type { AgentLoopMutableState } from './agent-loop-state.js'; import type { CompactHookRegistry } from './compact-hooks.js'; import { isContextOverflowError, describeError } from '../../provider/errors.js'; import { withSpan, turnAttributes } from '../../observability/tracing.js'; +import { mossMetrics } from '../../observability/index.js'; import { redactSensitiveData } from '../../observability/redact.js'; import { runAgentLoopLlmTurn } from './agent-loop-stream-helpers.js'; import { runOverflowRecovery } from './overflow-recovery.js'; @@ -131,7 +132,7 @@ export async function executeLlmTurn(params: ExecuteLlmTurnParams): Promise { span.addEvent('prompt_window', { @@ -189,6 +190,12 @@ export async function executeLlmTurn(params: ExecuteLlmTurnParams): Promise Date: Thu, 16 Jul 2026 17:37:15 +0800 Subject: [PATCH 12/21] feat(tools): wrap tool execution in moss.tool.invoke span + emit tool metrics Splits executeOneToolCall into a thin outer wrapper (span + metrics) and the unchanged inner logic. Each tool call becomes one moss.tool.invoke span carrying is_error/outcome attributes, plus moss.tool.invocations and moss.tool.invoke.duration metrics. The inner function's control flow and multiple early-return paths are untouched. Co-Authored-By: Claude --- .../src/core/tools/execute-tool-call.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/moss-agent/src/core/tools/execute-tool-call.ts b/packages/moss-agent/src/core/tools/execute-tool-call.ts index c08ffc60..0eaa8299 100644 --- a/packages/moss-agent/src/core/tools/execute-tool-call.ts +++ b/packages/moss-agent/src/core/tools/execute-tool-call.ts @@ -29,6 +29,8 @@ import { describeError, isTimeoutError, isTransientError } from '../../provider/ import { getRootLogger } from '../../logger.js'; import { runPreToolHookChain, validateToolInputObject } from './tool-pipeline.js'; import { MossError, ErrorCode, errorMessage } from '../../errors.js'; +import { withSpan, toolAttributes } from '../../observability/tracing.js'; +import { mossMetrics } from '../../observability/index.js'; const logger = getRootLogger(); @@ -242,6 +244,35 @@ export type ExecuteToolCallOutcome = export async function executeOneToolCall( call: { id: string; name: string; input: Record }, deps: ExecuteToolCallDeps +): Promise { + const startMs = Date.now(); + return withSpan( + 'moss.tool.invoke', + toolAttributes(deps.sessionKey, call.name, call.id), + async (span) => { + try { + const outcome = await executeOneToolCallInner(call, deps); + const isError = outcome.kind === 'completed' ? Boolean(outcome.isError) : true; + const durationMs = outcome.kind === 'completed' && typeof outcome.durationMs === 'number' + ? outcome.durationMs + : Date.now() - startMs; + span.setAttribute('is_error', isError); + if (outcome.kind === 'completed' && outcome.outcome) span.setAttribute('outcome', outcome.outcome); + mossMetrics.toolInvocations.add(1, { tool: call.name, status: isError ? 'error' : 'ok' }); + mossMetrics.toolDuration.record(durationMs, { tool: call.name }); + return outcome; + } catch (err) { + mossMetrics.toolInvocations.add(1, { tool: call.name, status: 'error' }); + mossMetrics.toolDuration.record(Date.now() - startMs, { tool: call.name }); + throw err; + } + }, + ); +} + +async function executeOneToolCallInner( + call: { id: string; name: string; input: Record }, + deps: ExecuteToolCallDeps ): Promise { try { From b88f09e40781c3ffa3ce37b77e4c9e7a713c0d49 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 17:46:04 +0800 Subject: [PATCH 13/21] feat(loop): wrap each loop turn in moss.agent.turn span Adds an imperative startSpan() / ActiveSpan API to tracing.ts for call-sites that cannot use the callback-based withSpan (loop bodies with continue/break/throw). Each agent-loop iteration opens a moss.agent.turn span; executeLlmTurn and processLlmResponse run inside runInSpanContext so child spans (moss.llm.request, moss.tool.invoke) nest under the turn. The span is ended in the existing finally block (runs on continue/break/throw), marking ERROR when the turn ended with a consecutive-turn error. Co-Authored-By: Claude --- .../moss-agent/src/core/loop/agent-loop.ts | 21 ++++++--- .../moss-agent/src/observability/index.ts | 3 +- .../moss-agent/src/observability/tracing.ts | 45 +++++++++++++++++++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/moss-agent/src/core/loop/agent-loop.ts b/packages/moss-agent/src/core/loop/agent-loop.ts index ce0e00db..4f4de789 100644 --- a/packages/moss-agent/src/core/loop/agent-loop.ts +++ b/packages/moss-agent/src/core/loop/agent-loop.ts @@ -34,6 +34,7 @@ import { createInitialLoopState, resetIterationState } from './agent-loop-state. import type { SteeringContext } from './steering.js'; import { prepareTurnContext, shouldIncludeThinkingInBudget } from './agent-loop-context-prep.js'; import { executeLlmTurn } from './agent-loop-llm-call.js'; +import { startSpan, turnAttributes } from '../../observability/tracing.js'; import { processLlmResponse } from './agent-loop-response.js'; export type { AgentLoopDeps, @@ -380,6 +381,11 @@ export function runAgentLoop( let turnToolCalls: { id: string; name: string; input: Record }[] = []; + // Open a turn span covering this iteration's LLM + tool work. + // runInSpanContext() activates it around executeLlmTurn/processLlmResponse + // so child spans (moss.llm.request, moss.tool.invoke) nest under it. + // The finally ends the span on every exit: continue, break, throw. + const turnSpan = startSpan('moss.agent.turn', turnAttributes(runId, state.turns, String(modelDef.id))); try { const ctxResult = await prepareTurnContext({ @@ -423,7 +429,7 @@ export function runAgentLoop( } - const llmResult = await executeLlmTurn({ + const llmResult = await turnSpan.runInSpanContext(() => executeLlmTurn({ state, modelDef, piContext: ctxResult.piContext, @@ -446,7 +452,7 @@ export function runAgentLoop( recordLlmUsage, lastMessageNeedsToolFollowUpLlm, suppressVisibleDeltas: Boolean(params.guardAssistantOutput || params.completionGate), - }); + })); if (llmResult.control === 'retry') { state.turns--; @@ -457,7 +463,7 @@ export function runAgentLoop( turnToolCalls = llmResult.toolCalls; - const responseResult = await processLlmResponse({ + const responseResult = await turnSpan.runInSpanContext(() => processLlmResponse({ state, runId, assistantContent: llmResult.assistantContent, @@ -492,7 +498,7 @@ export function runAgentLoop( appendMessage, push: (e) => stream.push(e), buildCorrectionMessage, - }); + })); state.consecutiveTurnErrors = 0; @@ -601,9 +607,12 @@ export function runAgentLoop( remainingBuffer: turnAssistantBuffer.length, sessionKey, }); - - + + + } + // Ends the turn span on every exit path: continue, break, throw. + turnSpan.end(state.consecutiveTurnErrors === 0); } } diff --git a/packages/moss-agent/src/observability/index.ts b/packages/moss-agent/src/observability/index.ts index 6e1c18bd..6be7435d 100644 --- a/packages/moss-agent/src/observability/index.ts +++ b/packages/moss-agent/src/observability/index.ts @@ -14,12 +14,13 @@ export { setTraceRedactor, getTracer, withSpan, + startSpan, turnAttributes, toolAttributes, llmRequestAttributes, sessionAttributes, } from './tracing.js'; -export type { Tracer, TraceSpan } from './tracing.js'; +export type { Tracer, TraceSpan, ActiveSpan } from './tracing.js'; export { mossMetrics } from './metrics.js'; export { FileSpanProcessor, readTraceStats } from './file-trace.js'; export type { SerializedSpan, TraceStats } from './file-trace.js'; diff --git a/packages/moss-agent/src/observability/tracing.ts b/packages/moss-agent/src/observability/tracing.ts index 7cb43efb..cc5c9872 100644 --- a/packages/moss-agent/src/observability/tracing.ts +++ b/packages/moss-agent/src/observability/tracing.ts @@ -62,6 +62,51 @@ export async function withSpan( }); } +/** + * Imperative span lifecycle, for call-sites that cannot use the callback-based + * withSpan (e.g. a loop body whose control flow uses continue/break/throw). + * + * startSpan() creates and activates a span; runInSpanContext() runs a fn within + * that span's context (so child withSpan calls nest under it); endSpan() ends + * it. Always call endSpan in a finally that covers every exit path. + */ +export interface ActiveSpan { + /** The underlying OTel Span. Set attributes / add events on it. */ + readonly span: Span; + /** Run fn within this span's active context (for child-span nesting). */ + runInSpanContext(fn: () => Promise): Promise; + /** End the span. ok=false records ERROR. Idempotent. */ + end(ok?: boolean, message?: string): void; +} + +export function startSpan( + name: string, + attributes?: Record, +): ActiveSpan { + const span = tracer.startSpan(name, attributes ? { attributes } : undefined); + const active = context.active(); + const spanContext = trace.setSpan(active, span); + let ended = false; + return { + span, + runInSpanContext(fn) { + return context.with(spanContext, fn); + }, + end(ok = true, message) { + if (ended) return; + ended = true; + try { + span.setStatus( + ok + ? { code: SpanStatusCode.OK } + : { code: SpanStatusCode.ERROR, ...(message ? { message } : {}) }, + ); + } catch { /* noop */ } + span.end(); + }, + }; +} + // ── Attributes constructors (span dimensions, centralized) ────────────── export function turnAttributes( From 753e1f1f36fbc29cf7173518f5271537ff208d5b Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 17:49:01 +0800 Subject: [PATCH 14/21] feat(agent): wrap chat() in moss.session root span + record session metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat() now runs its body inside a moss.session span (sessionKey + model attributes). Session metrics — count, duration, per-turn tool count — are recorded in a finally block so they fire on both success and error outcomes (ok/incomplete/error). The session span is the root of the session → turn → llm/tool trace tree. Co-Authored-By: Claude --- .../moss-agent/src/core/agent/moss-agent.ts | 67 ++++++++++++------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/packages/moss-agent/src/core/agent/moss-agent.ts b/packages/moss-agent/src/core/agent/moss-agent.ts index df41e894..1b0ee714 100644 --- a/packages/moss-agent/src/core/agent/moss-agent.ts +++ b/packages/moss-agent/src/core/agent/moss-agent.ts @@ -36,7 +36,8 @@ import { } from '@rdk-moss/core/contracts/async-task'; import { compactHistoryIfNeeded, type SummarizeFn } from '../../context/compaction.js'; import { createRemoteCompactProviderFromEnv } from '../../context/remote-compaction.js'; -import { setTraceRedactor } from '../../observability/tracing.js'; +import { setTraceRedactor, withSpan, sessionAttributes } from '../../observability/tracing.js'; +import { mossMetrics } from '../../observability/index.js'; import { PlatformExtensionRegistry, createAgentExtensionRegistryFromDefaults, @@ -410,30 +411,50 @@ export class MossAgent { async chat(sessionKey: string, userMessage: string, options?: ChatOptions): Promise { + const sessionStart = Date.now(); + const model = String(this.config.model); let finalResult: ChatResult | undefined; - let firstError: unknown; - let sawError = false; - for await (const event of this.streamChat(sessionKey, userMessage, options)) { - if (event.type === 'done') { - finalResult = event.result; - } else if (event.type === 'error') { - if (!sawError) { - firstError = event.error; - sawError = true; - } - } - } - if (sawError) { - throw new MossError({ - code: ErrorCode.INTERNAL_INVARIANT_VIOLATED, - message: formatAgentError(firstError), - }); + try { + finalResult = await withSpan( + 'moss.session', + sessionAttributes(sessionKey, model, sessionKey), + async () => { + let result: ChatResult | undefined; + let firstError: unknown; + let sawError = false; + for await (const event of this.streamChat(sessionKey, userMessage, options)) { + if (event.type === 'done') { + result = event.result; + } else if (event.type === 'error') { + if (!sawError) { + firstError = event.error; + sawError = true; + } + } + } + if (sawError) { + throw new MossError({ + code: ErrorCode.INTERNAL_INVARIANT_VIOLATED, + message: formatAgentError(firstError), + }); + } + if (result) return result; + throw new MossError({ + code: ErrorCode.INTERNAL_INVARIANT_VIOLATED, + message: 'agent stream ended without done or error event', + }); + }, + ); + return finalResult; + } finally { + // Record session metrics on every exit path (success or failure). + const outcome = finalResult + ? (finalResult.stopReason === 'end_turn' ? 'ok' : 'incomplete') + : 'error'; + mossMetrics.sessionCount.add(1, { outcome }); + mossMetrics.sessionDuration.record(Date.now() - sessionStart, { outcome }); + mossMetrics.sessionToolCount.record(finalResult?.toolCalls?.length ?? 0, { outcome }); } - if (finalResult) return finalResult; - throw new MossError({ - code: ErrorCode.INTERNAL_INVARIANT_VIOLATED, - message: 'agent stream ended without done or error event', - }); } private async loadGoalState(sessionKey: string): Promise<{ From 36a876ebf53588ec61b1aa4a6a8cc9847cd8312a Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 17:51:04 +0800 Subject: [PATCH 15/21] test(agent): add observability end-to-end integration spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables the SDK (MOSS_OTEL_ENABLED=1), runs a nested withSpan chain (session → turn → llm), shuts down, and asserts the FileSpanProcessor landed all three spans in traces.jsonl. Does not require a live receiver — OTLP sends are fire-and-forget, the file trace writes regardless. Proves the full trace+file stack works, not just compiles. Co-Authored-By: Claude --- .../test/observability-integration.spec.mjs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 packages/moss-agent/test/observability-integration.spec.mjs diff --git a/packages/moss-agent/test/observability-integration.spec.mjs b/packages/moss-agent/test/observability-integration.spec.mjs new file mode 100644 index 00000000..4a9b3c36 --- /dev/null +++ b/packages/moss-agent/test/observability-integration.spec.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// @rdk-moss/agent — End-to-end integration: enable SDK, run nested withSpan +// (session→turn→llm), verify the local file trace lands the three nested spans. +// Does NOT require a live receiver — OTLP sends are fire-and-forget; the +// FileSpanProcessor writes to disk regardless of network. +import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'index.js')).href); +const { initObservability, shutdownObservability, withSpan } = mod; +const traceMod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'file-trace.js')).href); +const { readTraceStats } = traceMod; + +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-int-')); +process.env.MOSS_OTEL_ENABLED = '1'; +process.env.MOSS_OTEL_URL = 'http://localhost:4318'; // receiver 未起,fire-and-forget 不抛 +process.env.MOSS_FILE_TRACE = '1'; + +initObservability({ workspaceDir: tmp }); + +// 三层 span:session → turn → llm(模拟 agent 调用栈) +await withSpan('moss.session', { runId: 'r1', model: 'm', sessionKey: 'sk' }, async () => { + return withSpan('moss.agent.turn', { runId: 'r1', turn: 1, model: 'm' }, async () => { + return withSpan('moss.llm.request', { runId: 'r1', model: 'm', inputTokens: 100 }, async (span) => { + span.setAttribute('outputTokens', 50); + return 'done'; + }); + }); +}); + +await shutdownObservability(); + +const file = path.join(tmp, '.moss', 'analytics', 'traces.jsonl'); +const stats = await readTraceStats(file); +assert.equal(stats.totalSpans, 3, 'three nested spans landed in file'); +assert.ok(stats.byName['moss.session'], 'session span present'); +assert.ok(stats.byName['moss.agent.turn'], 'turn span present'); +assert.ok(stats.byName['moss.llm.request'], 'llm span present'); + +await fs.rm(tmp, { recursive: true, force: true }); +delete process.env.MOSS_OTEL_ENABLED; +delete process.env.MOSS_OTEL_URL; +delete process.env.MOSS_FILE_TRACE; +console.error('[spec] observability-integration OK'); From e9044ce81477c94088f9b983fcd986c5168788c0 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 18:15:53 +0800 Subject: [PATCH 16/21] fix(observability): resolve metrics instruments lazily after SDK init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mossMetrics previously created its instruments at module load via metrics.getMeter() — which returns a NOOP meter until setGlobalMeterProvider runs. The cached noop instruments never switched to real ones after the SDK started, so metrics were silently dropped (verified: receiver got 0 metrics even with MOSS_OTEL_ENABLED=1). Instruments now resolve lazily on first use (cached after first resolution), so they bind to the real MeterProvider once the SDK starts. The e2e spec now asserts getMeterProvider() is the noop before init and a real provider after — a regression guard for this exact failure. Found via manual receiver verification (D:\otel), not the spec suite. Co-Authored-By: Claude --- .../moss-agent/src/observability/metrics.ts | 59 +++++++++++++++---- .../test/observability-integration.spec.mjs | 22 ++++++- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/moss-agent/src/observability/metrics.ts b/packages/moss-agent/src/observability/metrics.ts index a27fea50..30eef115 100644 --- a/packages/moss-agent/src/observability/metrics.ts +++ b/packages/moss-agent/src/observability/metrics.ts @@ -1,9 +1,15 @@ /** * OpenTelemetry metrics for Moss — instrument handles. * - * Uses the global MeterProvider. When none is registered (observability - * disabled), metrics.getMeter() returns a noop meter whose instruments are - * no-ops, so business code calls .add()/.record() unconditionally at zero cost. + * Instruments are resolved LAZILY from the global MeterProvider on first use, + * so they pick up the real MeterProvider once the SDK is started + * (sdk.ts → setGlobalMeterProvider). When observability is disabled (no + * provider registered), metrics.getMeter() returns a noop meter whose + * instruments are no-ops — business code calls .add()/.record() + * unconditionally at zero cost. + * + * Resolving eagerly at module load would bind to the noop meter before the SDK + * starts; the cached noop instruments would never switch to real ones. * * Usage: * import { mossMetrics } from './observability/index.js'; @@ -11,18 +17,49 @@ */ import { metrics } from '@opentelemetry/api'; -const meter = metrics.getMeter('moss-agent'); +const meter = () => metrics.getMeter('moss-agent'); + +type Counter = { add(value: number, attributes?: Record): void }; +type Histogram = { record(value: number, attributes?: Record): void }; + +function counter(name: string, opts?: { unit?: string }): () => Counter { + let cached: Counter | undefined; + return () => (cached ??= meter().createCounter(name, opts)); +} +function histogram(name: string, opts?: { unit?: string }): () => Histogram { + let cached: Histogram | undefined; + return () => (cached ??= meter().createHistogram(name, opts)); +} + +// Wrap a lazily-resolved instrument in a stable object exposing .add/.record +// so call-sites write `mossMetrics.x.add(...)` unchanged. +function counterHandle(name: string, opts?: { unit?: string }): Counter { + const get = counter(name, opts); + return { + add(value, attributes) { + try { get().add(value, attributes ?? {}); } catch { /* noop */ } + }, + }; +} +function histogramHandle(name: string, opts?: { unit?: string }): Histogram { + const get = histogram(name, opts); + return { + record(value, attributes) { + try { get().record(value, attributes ?? {}); } catch { /* noop */ } + }, + }; +} export const mossMetrics = { // LLM - llmTokens: meter.createCounter('moss.llm.tokens', { unit: '{token}' }), - llmDuration: meter.createHistogram('moss.llm.request.duration', { unit: 'ms' }), + llmTokens: counterHandle('moss.llm.tokens', { unit: '{token}' }), + llmDuration: histogramHandle('moss.llm.request.duration', { unit: 'ms' }), // tool - toolInvocations: meter.createCounter('moss.tool.invocations'), - toolDuration: meter.createHistogram('moss.tool.invoke.duration', { unit: 'ms' }), + toolInvocations: counterHandle('moss.tool.invocations'), + toolDuration: histogramHandle('moss.tool.invoke.duration', { unit: 'ms' }), // session - sessionCount: meter.createCounter('moss.session.count'), - sessionDuration: meter.createHistogram('moss.session.duration', { unit: 'ms' }), + sessionCount: counterHandle('moss.session.count'), + sessionDuration: histogramHandle('moss.session.duration', { unit: 'ms' }), // 每轮工具数(纠正 from-remote 把它误命名为 session.turns 的错位) - sessionToolCount: meter.createHistogram('moss.session.tool_count'), + sessionToolCount: histogramHandle('moss.session.tool_count'), }; diff --git a/packages/moss-agent/test/observability-integration.spec.mjs b/packages/moss-agent/test/observability-integration.spec.mjs index 4a9b3c36..d534b270 100644 --- a/packages/moss-agent/test/observability-integration.spec.mjs +++ b/packages/moss-agent/test/observability-integration.spec.mjs @@ -11,22 +11,42 @@ import os from 'node:os'; const dir = path.dirname(fileURLToPath(import.meta.url)); const mod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'index.js')).href); -const { initObservability, shutdownObservability, withSpan } = mod; +const { initObservability, shutdownObservability, withSpan, mossMetrics } = mod; const traceMod = await import(pathToFileURL(path.join(dir, '..', 'dist', 'observability', 'file-trace.js')).href); const { readTraceStats } = traceMod; +// api 'metrics' to assert the global MeterProvider got registered on init. +const otelApi = await import('@opentelemetry/api'); const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-int-')); process.env.MOSS_OTEL_ENABLED = '1'; process.env.MOSS_OTEL_URL = 'http://localhost:4318'; // receiver 未起,fire-and-forget 不抛 process.env.MOSS_FILE_TRACE = '1'; +// Before init: getMeterProvider() is the noop provider (constructor NoopMeterProvider). +// After init: SDK registered a real MeterProvider. This guards against the regression +// where mossMetrics binds to the noop meter at module load and never switches to real +// instruments (instruments must be resolved lazily AFTER setGlobalMeterProvider). +const providerBefore = otelApi.metrics.getMeterProvider(); +assert.equal(providerBefore.constructor.name, 'NoopMeterProvider', + 'no global MeterProvider before init (default noop)'); + initObservability({ workspaceDir: tmp }); +const providerAfter = otelApi.metrics.getMeterProvider(); +assert.notEqual(providerAfter.constructor.name, 'NoopMeterProvider', + 'initObservability registered a real MeterProvider (not noop)'); +assert.notStrictEqual(providerAfter, providerBefore, 'provider changed after init'); + // 三层 span:session → turn → llm(模拟 agent 调用栈) +// 在 llm span 内记一条 metric — 验证 instruments 在 SDK start 后解析为真 +// (lazy 解析;eager 绑定会卡在 noop meter)。 await withSpan('moss.session', { runId: 'r1', model: 'm', sessionKey: 'sk' }, async () => { return withSpan('moss.agent.turn', { runId: 'r1', turn: 1, model: 'm' }, async () => { return withSpan('moss.llm.request', { runId: 'r1', model: 'm', inputTokens: 100 }, async (span) => { span.setAttribute('outputTokens', 50); + mossMetrics.llmTokens.add(100, { direction: 'input', model: 'm' }); + mossMetrics.llmTokens.add(50, { direction: 'output', model: 'm' }); + mossMetrics.llmDuration.record(42, { model: 'm' }); return 'done'; }); }); From dcf2c6cf70ed3287806565669689bf877d3d61bd Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 19:33:13 +0800 Subject: [PATCH 17/21] fix(agent): move session span/metrics to streamChatViaAgentLoop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The moss.session root span and session metrics were in chat(), but the CLI (oneshot / piped / TUI) all drive the agent via streamChat() → streamChatViaAgentLoop, which never calls chat(). So the session span and session metrics were unreachable from any CLI entry — verified: real LLM oneshot produced only turn+llm spans, no moss.session, no session metrics. Moved the session span (startSpan) and session metrics into streamChatViaAgentLoop's finally, covering every entry. chat() is restored to its original event-loop logic (the mesh/channel path now also gets a single session span via streamChat). Verified end-to-end with a real LLM: moss.session → moss.agent.turn → moss.llm.request tree, real token metrics, and session metrics all land in the receiver. Co-Authored-By: Claude --- .../moss-agent/src/core/agent/moss-agent.ts | 85 +++++++++---------- 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/packages/moss-agent/src/core/agent/moss-agent.ts b/packages/moss-agent/src/core/agent/moss-agent.ts index 1b0ee714..3fc6ce18 100644 --- a/packages/moss-agent/src/core/agent/moss-agent.ts +++ b/packages/moss-agent/src/core/agent/moss-agent.ts @@ -36,7 +36,7 @@ import { } from '@rdk-moss/core/contracts/async-task'; import { compactHistoryIfNeeded, type SummarizeFn } from '../../context/compaction.js'; import { createRemoteCompactProviderFromEnv } from '../../context/remote-compaction.js'; -import { setTraceRedactor, withSpan, sessionAttributes } from '../../observability/tracing.js'; +import { setTraceRedactor, startSpan, sessionAttributes } from '../../observability/tracing.js'; import { mossMetrics } from '../../observability/index.js'; import { PlatformExtensionRegistry, @@ -411,50 +411,30 @@ export class MossAgent { async chat(sessionKey: string, userMessage: string, options?: ChatOptions): Promise { - const sessionStart = Date.now(); - const model = String(this.config.model); let finalResult: ChatResult | undefined; - try { - finalResult = await withSpan( - 'moss.session', - sessionAttributes(sessionKey, model, sessionKey), - async () => { - let result: ChatResult | undefined; - let firstError: unknown; - let sawError = false; - for await (const event of this.streamChat(sessionKey, userMessage, options)) { - if (event.type === 'done') { - result = event.result; - } else if (event.type === 'error') { - if (!sawError) { - firstError = event.error; - sawError = true; - } - } - } - if (sawError) { - throw new MossError({ - code: ErrorCode.INTERNAL_INVARIANT_VIOLATED, - message: formatAgentError(firstError), - }); - } - if (result) return result; - throw new MossError({ - code: ErrorCode.INTERNAL_INVARIANT_VIOLATED, - message: 'agent stream ended without done or error event', - }); - }, - ); - return finalResult; - } finally { - // Record session metrics on every exit path (success or failure). - const outcome = finalResult - ? (finalResult.stopReason === 'end_turn' ? 'ok' : 'incomplete') - : 'error'; - mossMetrics.sessionCount.add(1, { outcome }); - mossMetrics.sessionDuration.record(Date.now() - sessionStart, { outcome }); - mossMetrics.sessionToolCount.record(finalResult?.toolCalls?.length ?? 0, { outcome }); + let firstError: unknown; + let sawError = false; + for await (const event of this.streamChat(sessionKey, userMessage, options)) { + if (event.type === 'done') { + finalResult = event.result; + } else if (event.type === 'error') { + if (!sawError) { + firstError = event.error; + sawError = true; + } + } + } + if (sawError) { + throw new MossError({ + code: ErrorCode.INTERNAL_INVARIANT_VIOLATED, + message: formatAgentError(firstError), + }); } + if (finalResult) return finalResult; + throw new MossError({ + code: ErrorCode.INTERNAL_INVARIANT_VIOLATED, + message: 'agent stream ended without done or error event', + }); } private async loadGoalState(sessionKey: string): Promise<{ @@ -1564,6 +1544,13 @@ export class MossAgent { userMessage: string, options?: ChatOptions ): AsyncGenerator { + const model = String(this.config.model); + const sessionStart = Date.now(); + // Session root span covers every CLI entry (oneshot / piped / TUI all + // funnel through streamChat). turn → llm/tool child spans nest under it + // via the active context. + const sessionSpan = startSpan('moss.session', sessionAttributes(sessionKey, model, sessionKey)); + let sessionResult: { toolCalls?: unknown[]; stopReason?: string } | undefined; const run = await this.createAgentLoopRun(sessionKey, userMessage, options); const miniStream = runAgentLoop(run.params); @@ -1575,6 +1562,7 @@ export class MossAgent { const miniResult = await miniStream.result(); done = run.adapter.getDoneEvent(miniResult); + sessionResult = done?.result; @@ -1584,6 +1572,17 @@ export class MossAgent { } finally { + // Session metrics on every exit path (success or failure). + const outcome = sessionResult + ? (sessionResult.stopReason === 'end_turn' ? 'ok' : 'incomplete') + : 'error'; + mossMetrics.sessionCount.add(1, { outcome }); + mossMetrics.sessionDuration.record(Date.now() - sessionStart, { outcome }); + mossMetrics.sessionToolCount.record( + Array.isArray(sessionResult?.toolCalls) ? sessionResult!.toolCalls!.length : 0, + { outcome }, + ); + sessionSpan.end(outcome !== 'error'); if (done) { yield* this.teardownAgentLoopRun(run, done); } From 957f28baaba91a25476c5ac6c2e683fb176ba9d2 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 19:54:49 +0800 Subject: [PATCH 18/21] fix(observability): nest turn/llm spans under moss.session (context propagation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session span and the turn/llm child spans ended up in separate traces: moss.session alone in one trace, moss.agent.turn + moss.llm.request in another. Verified by tracing the real LLM run — turn.parent was NULL, not the session span. Two root causes, both fixed: 1. The session span was opened but runAgentLoop ran outside its context. Added runInSpanContextGen to ActiveSpan — it drives an async generator while re-activating the span context on each .next() resume (generators suspend at yield and lose the stack-local active context). 2. runAgentLoop spawns a background async IIFE that captures the context active at call time. It was called before the session context was activated. Moved the miniStream creation INSIDE the session-context generator so the background loop inherits the session context. Verified end-to-end with a real LLM: the newest trace now has all three spans in one traceId with session → turn → llm parent linkage correct. Co-Authored-By: Claude --- .../moss-agent/src/core/agent/moss-agent.ts | 34 +++++++++++-------- .../moss-agent/src/observability/tracing.ts | 21 ++++++++++++ 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/moss-agent/src/core/agent/moss-agent.ts b/packages/moss-agent/src/core/agent/moss-agent.ts index 3fc6ce18..661d8627 100644 --- a/packages/moss-agent/src/core/agent/moss-agent.ts +++ b/packages/moss-agent/src/core/agent/moss-agent.ts @@ -1552,25 +1552,29 @@ export class MossAgent { const sessionSpan = startSpan('moss.session', sessionAttributes(sessionKey, model, sessionKey)); let sessionResult: { toolCalls?: unknown[]; stopReason?: string } | undefined; const run = await this.createAgentLoopRun(sessionKey, userMessage, options); - const miniStream = runAgentLoop(run.params); let done: Extract | undefined; + // Run the agent loop inside the session span's context so child spans + // (moss.agent.turn, moss.llm.request, moss.tool.invoke) nest under + // moss.session — share its traceId with the session span as parent. + // runInSpanContextGen keeps the span's context active across the + // generator's yields/awaits (AsyncLocalStorage propagation). The mini + // stream is created INSIDE the generator so runAgentLoop's background + // async task inherits the session context at startup. try { - for await (const event of this.adaptMiniStreamEvents(miniStream, run)) { + for await (const event of sessionSpan.runInSpanContextGen( + async function* (self: MossAgent) { + const miniStream = runAgentLoop(run.params); + for await (const ev of self.adaptMiniStreamEvents(miniStream, run)) { + yield ev; + } + const miniResult = await miniStream.result(); + done = run.adapter.getDoneEvent(miniResult); + sessionResult = done?.result; + }.call(this, this), + )) { yield event; } - - const miniResult = await miniStream.result(); - done = run.adapter.getDoneEvent(miniResult); - sessionResult = done?.result; - - - - - - - - } finally { // Session metrics on every exit path (success or failure). const outcome = sessionResult @@ -1588,7 +1592,7 @@ export class MossAgent { } } - yield done; + yield done!; } async *streamChat( diff --git a/packages/moss-agent/src/observability/tracing.ts b/packages/moss-agent/src/observability/tracing.ts index cc5c9872..a7a539a5 100644 --- a/packages/moss-agent/src/observability/tracing.ts +++ b/packages/moss-agent/src/observability/tracing.ts @@ -75,6 +75,13 @@ export interface ActiveSpan { readonly span: Span; /** Run fn within this span's active context (for child-span nesting). */ runInSpanContext(fn: () => Promise): Promise; + /** + * Drive an async generator within this span's active context. For + * async-generator call-sites (e.g. streamChat) that must yield to the + * caller while keeping the span context active across awaits, so child + * spans created inside the generator nest under this span. + */ + runInSpanContextGen(gen: AsyncGenerator): AsyncGenerator; /** End the span. ok=false records ERROR. Idempotent. */ end(ok?: boolean, message?: string): void; } @@ -92,6 +99,20 @@ export function startSpan( runInSpanContext(fn) { return context.with(spanContext, fn); }, + runInSpanContextGen(gen: AsyncGenerator): AsyncGenerator { + // Generators suspend at yield and lose the stack-local active context on + // the next .next(). Drive the generator through a thin wrapper that + // re-activates the span context on each resume, so child spans created + // inside the generator nest under this span. + const ctx = spanContext; + return (async function* delegate() { + while (true) { + const res = await context.with(ctx, async () => gen.next()); + if (res.done) return res.value as T; + yield res.value as T; + } + })(); + }, end(ok = true, message) { if (ended) return; ended = true; From c1c83f7bbbc9a8f969f39138b79e2b0becd73853 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Thu, 16 Jul 2026 20:16:13 +0800 Subject: [PATCH 19/21] fix(observability): refine session outcome + tool error classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two minor metric-semantic issues found during runtime verification: 1. session outcome: a hard LLM failure (HTTP 401) produced stopReason='error' but was labeled 'incomplete' (only 'end_turn' was 'ok', everything else 'incomplete'). Now 'error' stopReason → outcome='error', so the session error-rate reflects real failures. Verified: 401 run now records session.count outcome=error (was incomplete). 2. tool span/metrics: denied / pre-blocked / hook-blocked / unknown-tool outcomes were all marked is_error and counted as status='error', inflating the tool error-rate. Now only a completed-and-failed execution is status='error'; the pre-execution outcomes count as 'blocked' (user refusal / guardrail / unknown tool are not execution errors). Added outcome_kind span attribute. Verified: successful exec → status='ok'. Co-Authored-By: Claude --- packages/moss-agent/src/core/agent/moss-agent.ts | 5 ++++- .../moss-agent/src/core/tools/execute-tool-call.ts | 13 ++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/moss-agent/src/core/agent/moss-agent.ts b/packages/moss-agent/src/core/agent/moss-agent.ts index 661d8627..b361d32b 100644 --- a/packages/moss-agent/src/core/agent/moss-agent.ts +++ b/packages/moss-agent/src/core/agent/moss-agent.ts @@ -1577,8 +1577,11 @@ export class MossAgent { } } finally { // Session metrics on every exit path (success or failure). + // end_turn → ok; explicit error stopReason → error; otherwise incomplete. const outcome = sessionResult - ? (sessionResult.stopReason === 'end_turn' ? 'ok' : 'incomplete') + ? (sessionResult.stopReason === 'end_turn' + ? 'ok' + : sessionResult.stopReason === 'error' ? 'error' : 'incomplete') : 'error'; mossMetrics.sessionCount.add(1, { outcome }); mossMetrics.sessionDuration.record(Date.now() - sessionStart, { outcome }); diff --git a/packages/moss-agent/src/core/tools/execute-tool-call.ts b/packages/moss-agent/src/core/tools/execute-tool-call.ts index 0eaa8299..883c5ff8 100644 --- a/packages/moss-agent/src/core/tools/execute-tool-call.ts +++ b/packages/moss-agent/src/core/tools/execute-tool-call.ts @@ -252,13 +252,20 @@ export async function executeOneToolCall( async (span) => { try { const outcome = await executeOneToolCallInner(call, deps); - const isError = outcome.kind === 'completed' ? Boolean(outcome.isError) : true; + // Only a completed-and-failed execution is a real tool error. + // denied (user refusal) / pre-blocked / hook-blocked / unknown-tool are + // not execution errors — classify as 'blocked' so the tool error-rate + // metric doesn't over-count them. + const isExecError = outcome.kind === 'completed' && Boolean(outcome.isError); + const metricStatus: string = + outcome.kind === 'completed' ? (isExecError ? 'error' : 'ok') : 'blocked'; const durationMs = outcome.kind === 'completed' && typeof outcome.durationMs === 'number' ? outcome.durationMs : Date.now() - startMs; - span.setAttribute('is_error', isError); + span.setAttribute('is_error', isExecError); + span.setAttribute('outcome_kind', outcome.kind); if (outcome.kind === 'completed' && outcome.outcome) span.setAttribute('outcome', outcome.outcome); - mossMetrics.toolInvocations.add(1, { tool: call.name, status: isError ? 'error' : 'ok' }); + mossMetrics.toolInvocations.add(1, { tool: call.name, status: metricStatus }); mossMetrics.toolDuration.record(durationMs, { tool: call.name }); return outcome; } catch (err) { From 37a95981b512699505d2697e187f1069ee17d7ec Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Fri, 17 Jul 2026 21:11:05 +0800 Subject: [PATCH 20/21] feat(observability): add ConsoleSpanProcessor for MOSS_TRACE=console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MOSS_TRACE=console (documented in cli help) was a dead switch — the old setTracer('console') became a noop shim after the SDK rewrite. Restore it as a real ConsoleSpanProcessor that streams span open/close to stderr with parent-depth indentation, duration, status, and key attributes — useful for watching the trace tree run in a terminal without a receiver. Also fixes two lazy-resolution bugs exposed while wiring this up: - tracing.ts bound `tracer` eagerly at module load (noop before SDK start), same failure mode as the metrics instruments. Now resolved lazily via resolveTracer() so spans bind to the real TracerProvider after SDK start. - sdk.ts returned early when cfg.enabled=false, blocking the standalone console-only path. Now starts the SDK if either OTel or console is on. Verified: real LLM oneshot with a tool call streams the full session → turn → llm/tool tree to stderr with correct nesting. Co-Authored-By: Claude --- .../src/observability/console-trace.ts | 68 +++++++++++++++++++ .../moss-agent/src/observability/index.ts | 18 +++-- packages/moss-agent/src/observability/sdk.ts | 25 +++++-- .../moss-agent/src/observability/tracing.ts | 11 ++- 4 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 packages/moss-agent/src/observability/console-trace.ts diff --git a/packages/moss-agent/src/observability/console-trace.ts b/packages/moss-agent/src/observability/console-trace.ts new file mode 100644 index 00000000..8306f4b6 --- /dev/null +++ b/packages/moss-agent/src/observability/console-trace.ts @@ -0,0 +1,68 @@ +/** + * ConsoleSpanProcessor — emits a human-readable span lifecycle to stderr. + * + * For local debugging: MOSS_TRACE=console attaches this processor so you can + * watch the trace tree stream by as the agent runs (start/end per span, + * indented by parent depth, with duration + status). Zero network, zero disk. + * + * Uses SDK span lifecycle: onStart opens the line, onEnd completes it with + * duration + status. Because spans end out of order (children end before + * parents), we print on END (when duration/status are known) with a depth + * derived from the span's nesting via a parent stack — approximated by + * tracking open spans and their parent ids. + */ +import type { SpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { Context } from '@opentelemetry/api'; +import { SpanStatusCode } from '@opentelemetry/api'; + +interface OpenSpan { + name: string; + spanId: string; + parentSpanId?: string; + depth: number; +} + +export class ConsoleSpanProcessor implements SpanProcessor { + private open = new Map(); + + onStart(span: ReadableSpan, _parentContext: Context): void { + const spanId = span.spanContext().spanId; + const parentSpanId = span.parentSpanContext?.spanId; + // Depth: parent's depth + 1, or 0 for a root. + const parentDepth = parentSpanId ? this.open.get(parentSpanId)?.depth ?? 0 : -1; + const depth = parentDepth + 1; + this.open.set(spanId, { name: span.name, spanId, parentSpanId, depth }); + const indent = ' '.repeat(depth); + process.stderr.write(`[trace] ${indent}▶ ${span.name}\n`); + } + + onEnd(span: ReadableSpan): void { + const spanId = span.spanContext().spanId; + const info = this.open.get(spanId); + const depth = info?.depth ?? 0; + const indent = ' '.repeat(depth); + const durMs = Number(span.duration[0]) * 1000 + Math.trunc(Number(span.duration[1]) / 1_000_000); + const status = span.status.code === SpanStatusCode.ERROR ? 'ERROR' : 'ok'; + const msg = span.status.message ? ` (${span.status.message.slice(0, 100)})` : ''; + const attrs = this.formatAttrs(span); + process.stderr.write(`[trace] ${indent}◀ ${span.name} (${durMs}ms, ${status})${attrs}${msg}\n`); + this.open.delete(spanId); + } + + private formatAttrs(span: ReadableSpan): string { + const entries = Object.entries(span.attributes ?? {}); + if (entries.length === 0) return ''; + const shown = entries + .filter(([k]) => !['runId', 'sessionKey'].includes(k)) + .slice(0, 4) + .map(([k, v]) => `${k}=${typeof v === 'string' ? v : String(v)}`) + .join(' '); + return shown ? ` {${shown}}` : ''; + } + + async forceFlush(): Promise {} + + async shutdown(): Promise { + this.open.clear(); + } +} diff --git a/packages/moss-agent/src/observability/index.ts b/packages/moss-agent/src/observability/index.ts index 6be7435d..52c25eb8 100644 --- a/packages/moss-agent/src/observability/index.ts +++ b/packages/moss-agent/src/observability/index.ts @@ -44,16 +44,22 @@ export interface InitOptions { } export function initObservability(opts: InitOptions): void { - const enabled = process.env.MOSS_OTEL_ENABLED === '1' || !!process.env.MOSS_OTEL_URL; - if (!enabled) return; + const otelEnabled = process.env.MOSS_OTEL_ENABLED === '1' || !!process.env.MOSS_OTEL_URL; + // MOSS_TRACE=console streams spans to stderr for local debugging, and can + // run on its own (no OTLP receiver needed). + const consoleTrace = process.env.MOSS_TRACE === 'console' + || process.env.MOSS_TRACE === '1' + || process.env.MOSS_TRACE === 'true'; + if (!otelEnabled && !consoleTrace) return; initObservabilitySdk({ serviceName: process.env.MOSS_OTEL_SERVICE_NAME ?? opts.serviceName ?? 'moss', otlpUrl: process.env.MOSS_OTEL_URL ?? opts.otlpUrl ?? 'http://localhost:4318', - enabled: true, + enabled: otelEnabled, // tracing 开则 metrics 默认开(纠正 from-remote 两开关易漏配) - metricsEnabled: process.env.MOSS_METRICS_ENABLED !== '0', - // 本地文件 trace 默认开,MOSS_FILE_TRACE=0 关 - fileTraceEnabled: process.env.MOSS_FILE_TRACE !== '0', + metricsEnabled: otelEnabled && process.env.MOSS_METRICS_ENABLED !== '0', + // 本地文件 trace 默认开(仅 OTel 启用时),MOSS_FILE_TRACE=0 关 + fileTraceEnabled: otelEnabled && process.env.MOSS_FILE_TRACE !== '0', + consoleTraceEnabled: consoleTrace, workspaceDir: opts.workspaceDir, }); } diff --git a/packages/moss-agent/src/observability/sdk.ts b/packages/moss-agent/src/observability/sdk.ts index 68c6934d..bb5072de 100644 --- a/packages/moss-agent/src/observability/sdk.ts +++ b/packages/moss-agent/src/observability/sdk.ts @@ -16,6 +16,7 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic import fs from 'node:fs'; import path from 'node:path'; import { FileSpanProcessor } from './file-trace.js'; +import { ConsoleSpanProcessor } from './console-trace.js'; import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; export interface ObservabilityConfig { @@ -24,6 +25,7 @@ export interface ObservabilityConfig { enabled: boolean; metricsEnabled: boolean; fileTraceEnabled: boolean; + consoleTraceEnabled: boolean; workspaceDir: string; } @@ -40,21 +42,32 @@ function readPackageVersion(): string { } export function initObservabilitySdk(cfg: ObservabilityConfig): void { - if (!cfg.enabled || sdk) return; + // Start the SDK if anything is enabled: OTel tracing/metrics, local file + // trace, or console trace (MOSS_TRACE=console runs standalone). Guarded + // against double-init via the `sdk` singleton below. + const wantsStart = cfg.enabled || cfg.consoleTraceEnabled; + if (!wantsStart || sdk) return; try { const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: cfg.serviceName, [ATTR_SERVICE_VERSION]: readPackageVersion(), }); - const spanProcessors: SpanProcessor[] = [ - new BatchSpanProcessor(new OTLPTraceExporter({ url: `${cfg.otlpUrl}/v1/traces` })), - ]; - if (cfg.fileTraceEnabled) { + const spanProcessors: SpanProcessor[] = []; + if (cfg.consoleTraceEnabled) { + spanProcessors.push(new ConsoleSpanProcessor()); + } + if (cfg.enabled) { + spanProcessors.push(new BatchSpanProcessor(new OTLPTraceExporter({ url: `${cfg.otlpUrl}/v1/traces` }))); + } + if (cfg.enabled && cfg.fileTraceEnabled) { spanProcessors.push(new FileSpanProcessor(cfg.workspaceDir)); } - const metricReaders = cfg.metricsEnabled + // If nothing is enabled (no console, no OTel), don't start the SDK at all. + if (spanProcessors.length === 0 && !cfg.metricsEnabled) return; + + const metricReaders = (cfg.enabled && cfg.metricsEnabled) ? [new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ url: `${cfg.otlpUrl}/v1/metrics` }), exportIntervalMillis: 10_000, diff --git a/packages/moss-agent/src/observability/tracing.ts b/packages/moss-agent/src/observability/tracing.ts index a7a539a5..957cf500 100644 --- a/packages/moss-agent/src/observability/tracing.ts +++ b/packages/moss-agent/src/observability/tracing.ts @@ -14,7 +14,12 @@ import type { Span } from '@opentelemetry/api'; import { errorMessage } from '../errors.js'; import { redactSensitiveData } from './redact.js'; -const tracer = trace.getTracer('moss-agent'); +// Resolve the tracer lazily so it picks up the real TracerProvider once the +// SDK is started (sdk.ts → setGlobalTracerProvider). Binding eagerly at module +// load would capture the noop tracer before the SDK starts — same failure mode +// as the metrics instruments (which are also lazy). Returns a noop tracer when +// no provider is registered, so withSpan/startSpan are zero-cost when disabled. +const resolveTracer = () => trace.getTracer('moss-agent'); /** Public span handle passed to withSpan's fn (mirrors the OTel Span API). */ export interface TraceSpan { @@ -43,7 +48,7 @@ export async function withSpan( attributes: Record | undefined, fn: (span: Span) => Promise, ): Promise { - const span = tracer.startSpan(name, attributes ? { attributes } : undefined); + const span = resolveTracer().startSpan(name, attributes ? { attributes } : undefined); return context.with(trace.setSpan(context.active(), span), async () => { try { const result = await fn(span); @@ -90,7 +95,7 @@ export function startSpan( name: string, attributes?: Record, ): ActiveSpan { - const span = tracer.startSpan(name, attributes ? { attributes } : undefined); + const span = resolveTracer().startSpan(name, attributes ? { attributes } : undefined); const active = context.active(); const spanContext = trace.setSpan(active, span); let ended = false; From ba78605c805595cfbb603cb06049f5ece092ceb9 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Mon, 20 Jul 2026 09:19:07 +0800 Subject: [PATCH 21/21] fix(hygiene): skip fenced/indented code blocks when scanning markdown links The link extractor matched TS computed-key syntax inside example code (e.g. `[semconv.ATTR_SERVICE_NAME]: cfg.serviceName,`) as a markdown reference definition (`[name]: url`), flagging `cfg.serviceName,` etc. as broken links. Strip fenced (```/~~~) and indented code blocks before extracting links so code in examples is not scanned. Fixes the workspace-hygiene failures that blocked `npm run verify` for the observability instrumentation docs. Co-Authored-By: Claude --- scripts/check-workspace-hygiene.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/check-workspace-hygiene.mjs b/scripts/check-workspace-hygiene.mjs index 4b3ab9c2..920a4a8c 100644 --- a/scripts/check-workspace-hygiene.mjs +++ b/scripts/check-workspace-hygiene.mjs @@ -46,14 +46,27 @@ function markdownAnchors(body) { return anchors; } +function stripCodeBlocks(body) { + // Remove fenced code blocks (``` or ~~~) AND their markers, so TS + // computed-key syntax like `[ATTR]: value,` inside an example is not + // mistaken for a markdown reference definition (`[name]: url`). + // CommonMark: a fenced code block opens with up to 3 leading spaces followed + // by ``` or ~~~ (optionally a language string) and closes with a fence of + // the same kind with at least as many ticks. + return body + .replace(/^[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n[ \t]{0,3}\1[ \t]*$/gm, '') + .replace(/^( {4}|\t).*$/gm, ''); +} + function findMarkdownLinks(body) { + const stripped = stripCodeBlocks(body); const links = []; const inlineLink = /!?\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; - for (const match of body.matchAll(inlineLink)) { + for (const match of stripped.matchAll(inlineLink)) { links.push(match[1]); } const refDef = /^\s*\[[^\]]+]:\s+(\S+)/gm; - for (const match of body.matchAll(refDef)) { + for (const match of stripped.matchAll(refDef)) { links.push(match[1]); } return links;