@turmind/halo 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/132-29179dca2ef093ce.js +1 -0
  3. package/admin-out/_next/static/chunks/{330.3b784cf0dcd69163.js → 170.14d53d7f32df020c.js} +1 -1
  4. package/admin-out/_next/static/chunks/187.9e822f6f0d366d9a.js +1 -0
  5. package/admin-out/_next/static/chunks/188.0cc6998eb0ed3519.js +1 -0
  6. package/admin-out/_next/static/chunks/{1d2d5650-810a3ca1ae20cab5.js → 1d2d5650-f5c38b9df5ca7466.js} +1 -1
  7. package/admin-out/_next/static/chunks/{304eda7d.b1ee7f523653e027.js → 304eda7d.662c3a1a7cd50701.js} +1 -1
  8. package/admin-out/_next/static/chunks/311.3cfaf322ec2a9170.js +1 -0
  9. package/admin-out/_next/static/chunks/401.c5e64882d4305a08.js +1 -0
  10. package/admin-out/_next/static/chunks/{443.d1b289781793f8de.js → 444.c191bedee971e484.js} +1 -1
  11. package/admin-out/_next/static/chunks/496.7997fc6558c88c22.js +1 -0
  12. package/admin-out/_next/static/chunks/530.03c0a6ec971d3528.js +1 -0
  13. package/admin-out/_next/static/chunks/540.a663b46781b2c309.js +1 -0
  14. package/admin-out/_next/static/chunks/556.10e21b9f7b45d412.js +1 -0
  15. package/admin-out/_next/static/chunks/813-9056b8ef0984bee6.js +1 -0
  16. package/admin-out/_next/static/chunks/{533.5f2d4bfe3d076095.js → 841.48a39cec2ef962f3.js} +1 -1
  17. package/admin-out/_next/static/chunks/891.cfe754015339e975.js +1 -0
  18. package/admin-out/_next/static/chunks/915.c6ee9ab9bee0ce9b.js +1 -0
  19. package/admin-out/_next/static/chunks/957-48c734b74e5437a8.js +1 -0
  20. package/admin-out/_next/static/chunks/968.1676baa9449dbd9d.js +1 -0
  21. package/admin-out/_next/static/chunks/app/_not-found/page-2ea4ee5eada04f69.js +1 -0
  22. package/admin-out/_next/static/chunks/app/layout-1853d21b0644f5a1.js +1 -0
  23. package/admin-out/_next/static/chunks/app/page-2ab109c9a25f867e.js +1 -0
  24. package/admin-out/_next/static/chunks/db534642.a148101e95a7d317.js +1 -0
  25. package/admin-out/_next/static/chunks/e3080c2c-fbf81b7cad547976.js +1 -0
  26. package/admin-out/_next/static/chunks/framework-67e5f0af3ea64f2b.js +1 -0
  27. package/admin-out/_next/static/chunks/main-6b28974253a3bc7c.js +1 -0
  28. package/admin-out/_next/static/chunks/main-app-3367dd99b8642c55.js +1 -0
  29. package/admin-out/_next/static/chunks/pages/_app-51a53534a0a4a898.js +1 -0
  30. package/admin-out/_next/static/chunks/pages/_error-dfd19566fa6f79ad.js +1 -0
  31. package/admin-out/_next/static/chunks/webpack-bf41edfde9cdefec.js +1 -0
  32. package/admin-out/_next/static/{AB2GFuqDjcxue9wBBq1zq → dpIz5l3hhYjL5gL4n3Huj}/_buildManifest.js +1 -1
  33. package/admin-out/index.html +1 -1
  34. package/admin-out/index.txt +13 -13
  35. package/bundled-docs/design/agentcore.md +9 -0
  36. package/bundled-docs/design/observability.md +214 -0
  37. package/bundled-docs/design/session.md +2 -0
  38. package/bundled-docs/design/storage.md +4 -0
  39. package/bundled-docs/dev/api.md +9 -0
  40. package/bundled-docs/dev/deploy.md +21 -1
  41. package/bundled-docs/dev/env.md +4 -0
  42. package/bundled-docs/guide/workspace.md +1 -0
  43. package/dist/index.js +433 -37
  44. package/package.json +12 -1
  45. package/admin-out/_next/static/chunks/133-e2df20fc6362a02b.js +0 -1
  46. package/admin-out/_next/static/chunks/200.dd28be896c7765de.js +0 -1
  47. package/admin-out/_next/static/chunks/220.e5a2532bda77e816.js +0 -1
  48. package/admin-out/_next/static/chunks/438-866a1a20e9efa78a.js +0 -1
  49. package/admin-out/_next/static/chunks/442.14eec8e615cc727b.js +0 -1
  50. package/admin-out/_next/static/chunks/515.b793a7455c5e8342.js +0 -1
  51. package/admin-out/_next/static/chunks/525.fe35ab8501121729.js +0 -1
  52. package/admin-out/_next/static/chunks/579.3ab0b2bb9244fc37.js +0 -1
  53. package/admin-out/_next/static/chunks/635-d7fd95bff8674116.js +0 -1
  54. package/admin-out/_next/static/chunks/65.3aac567c42cec99c.js +0 -1
  55. package/admin-out/_next/static/chunks/688.4e014ac6cfc7e152.js +0 -1
  56. package/admin-out/_next/static/chunks/868.0c95dfe283400a00.js +0 -1
  57. package/admin-out/_next/static/chunks/896.2069ab525ef81643.js +0 -1
  58. package/admin-out/_next/static/chunks/935.91ddb965b44041c9.js +0 -1
  59. package/admin-out/_next/static/chunks/app/_not-found/page-8e35399c8fd5016d.js +0 -1
  60. package/admin-out/_next/static/chunks/app/layout-e75df4cae90cf325.js +0 -1
  61. package/admin-out/_next/static/chunks/app/page-ca15438e8bddb403.js +0 -1
  62. package/admin-out/_next/static/chunks/db534642.09d3c650ff587175.js +0 -1
  63. package/admin-out/_next/static/chunks/fb4ec4d6-3fa44f69484f91b9.js +0 -1
  64. package/admin-out/_next/static/chunks/framework-c3031ae54a63fe16.js +0 -1
  65. package/admin-out/_next/static/chunks/main-14b48e3c2588354f.js +0 -1
  66. package/admin-out/_next/static/chunks/main-app-483b436873410ddc.js +0 -1
  67. package/admin-out/_next/static/chunks/pages/_app-b2fc6e5f920d1b1a.js +0 -1
  68. package/admin-out/_next/static/chunks/pages/_error-f3696a8f3d67d150.js +0 -1
  69. package/admin-out/_next/static/chunks/webpack-f80c9b8cca4447a3.js +0 -1
  70. /package/admin-out/_next/static/{AB2GFuqDjcxue9wBBq1zq → dpIz5l3hhYjL5gL4n3Huj}/_ssgManifest.js +0 -0
@@ -0,0 +1,214 @@
1
+ # Observability (OpenTelemetry)
2
+
3
+ ## Overview
4
+
5
+ A vendor-neutral observability layer built on the official OpenTelemetry JS SDK. One setting, `general.observability.endpoint`, turns on OTLP http/protobuf export of traces, metrics and logs to whatever collector the operator points at — empty means fully off. Principle: zero AWS (or any vendor) code in the server — SigV4 signing, backend endpoints, and resource enrichment all live in the collector, not in halo.
6
+
7
+ ## Configuration
8
+
9
+ | key | type | default | description |
10
+ |---|---|---|---|
11
+ | `general.observability.endpoint` | string | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`). Traces, metrics and logs all exported over OTLP http/protobuf — `http://` or `https://`, on the collector's OTLP/HTTP port (4318 by default); gRPC (4317) not supported. Empty = off. Restart required. |
12
+ | `general.observability.service_name` | string | `halo` | OTel resource `service.name`. Restart required. |
13
+ | `general.observability.headers` | string, **secret** | `''` | Extra headers for every OTLP request, comma-separated `k=v` (e.g. `authorization=Bearer …`). Restart required. |
14
+ | `general.observability.capture_content` | boolean | `false` | Put prompt / completion / tool argument+result text on spans (`gen_ai.*.messages` etc.). Off = metadata only (model, tokens, latency, tool names). Restart required. |
15
+
16
+ All four keys are `globalOnly` and take effect on server restart — `otel.ts` reads them once at boot, before anything else touches the OTel API.
17
+
18
+ **Env-var mapping.** `initObservability()` maps settings onto the standard `OTEL_*` env vars with `??=`, so an operator who already exports them (or `--require`s an external distro) wins:
19
+
20
+ - `endpoint` → `OTEL_EXPORTER_OTLP_ENDPOINT`
21
+ - `headers` → `OTEL_EXPORTER_OTLP_HEADERS`
22
+ - `service_name` → `OTEL_SERVICE_NAME`
23
+ - always `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` (declarative — the `*-otlp-proto` exporters are hard-wired to it; this just makes an externally-loaded SDK agree on wire protocol)
24
+
25
+ No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObservability()` returns without registering anything.
26
+
27
+ **External-SDK detection.** If an external SDK is already registered (e.g. via `node --require` of a vendor distro) — detected by comparing `ProxyTracerProvider.getDelegate()` against a fresh proxy's `NoopTracerProvider` delegate — halo logs `[Observability] external OpenTelemetry SDK already registered — using it, skipping built-in exporters` and skips its own exporters. The `??=` env mapping above still applies first, so the external SDK picks up halo's settings unless it already set its own.
28
+
29
+ **TLS / HTTPS.** `https://` endpoints work out of the box against CA-signed certs — the exporter picks `node:http` vs `node:https` by URL scheme. Self-signed certs / mTLS go through the standard exporter env vars `OTEL_EXPORTER_OTLP_CERTIFICATE` / `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` / `OTEL_EXPORTER_OTLP_CLIENT_KEY` (read by the exporter itself, not by halo code). Verified: a self-signed sink is rejected with `DEPTH_ZERO_SELF_SIGNED_CERT` unless the cert is trusted — no silent bypass.
30
+
31
+ **Common pitfall**: pointing `endpoint` at 4317 (the collector's gRPC port) instead of 4318 (OTLP/HTTP). Exports then fail silently — the batch processor swallows the error, at most one diagnostic line, no user-visible failure.
32
+
33
+ ## Architecture
34
+
35
+ | module | role |
36
+ |---|---|
37
+ | `packages/server/src/observability/otel.ts` (91 lines) | Bootstrap. `initObservability()` / `shutdownObservability()`, the `enabled` gate, `tracer` / `getMeter()` / `otelLogger` / `captureContent()` exports. |
38
+ | `packages/server/src/observability/otel-sdk.ts` (47 lines) | The ONE module that imports the SDK packages and OTLP exporters. |
39
+ | `packages/server/src/observability/genai-spans.ts` (296 lines) | The span model: `beginTurn` / `onAgentEvent` / `recordRetry` / `endTurn`. |
40
+ | `packages/server/src/agents/session-manager.ts` | Calls the four hooks above from the turn loop (see below). |
41
+ | `packages/server/src/logger.ts` | Bridges every `logger.*` call into an OTel LogRecord when `enabled`. |
42
+ | `packages/server/src/index.ts` | `await initObservability()` (L262, before `initLogger()`) and `await shutdownObservability()` (L599, in `gracefulShutdown`). |
43
+
44
+ **Hook call sites in `session-manager.ts`**: `beginTurn(session, message)` at L1276 (start of `runAgentTurn`), `onAgentEvent(session, event)` at L1316 (inside the agent event loop, one call per `AgentEvent`), `recordRetry(kind)` at the 7 retry catch sites, `endTurn(session, {error?})` at L1547 (the turn's single exit point, success or failure).
45
+
46
+ **The `enabled` gate.** `otel.ts` exports a single `enabled: boolean`, set once by `initObservability()`. Every hook in `genai-spans.ts` and the `logger.ts` bridge checks it first — an unconfigured server pays nothing beyond one boolean read per hook call.
47
+
48
+ **Why `otel-sdk.ts` is a dynamic import.** `logger.ts` is shared with the CLI (`halo cli`), which never wants observability. Eagerly importing the SDK module graph would cost ~100ms on every `halo cli` start. `otel.ts` only `import()`s `otel-sdk.ts` after confirming an endpoint is configured, so the unconfigured path (server or CLI) never loads it.
49
+
50
+ **Why the meter is lazy.** The `@opentelemetry/api` tracer and logger are proxies — they stay no-op until a real provider is registered, then transparently start forwarding, so `tracer` / `otelLogger` can be module-level consts. The metrics API has **no proxy**: a `Meter` fetched via `metrics.getMeter()` before `setGlobalMeterProvider()` runs is a permanent no-op forever after, even once a provider registers later. `getMeter()` in `otel.ts` is therefore a function, not a const, and `genai-spans.ts` calls it lazily on first metric emission (`instruments()`, cached after).
51
+
52
+ **Dependencies** (`packages/server/package.json`): `@opentelemetry/api` 1.9.1, `api-logs` 0.222.0, `sdk-trace-node` / `sdk-trace-base` / `sdk-metrics` / `sdk-logs` / `resources` 2.11.0, `exporter-trace-otlp-proto` / `exporter-metrics-otlp-proto` / `exporter-logs-otlp-proto` 0.222.0, `semantic-conventions` 1.43.0. No gRPC exporter (see [Rejected alternatives](#rejected-alternatives)).
53
+
54
+ **Test coverage**: `packages/server/test/observability-genai-spans.test.ts` (7 cases) drives `beginTurn` / `onAgentEvent` / `endTurn` against an in-memory tracer + `InMemorySpanExporter`, asserting the three-level span tree, parent ids, semconv attribute names, and the `capture_content` on/off split.
55
+
56
+ ## Span model
57
+
58
+ OTel GenAI semantic conventions. Scope `opentelemetry.instrumentation.halo`, version `1`. Hierarchy: **session** (`session.id`, whole conversation) ⊃ **trace** (one turn = one `invoke_agent` span) ⊃ **span**. All spans `kind=INTERNAL`, all carry `gen_ai.system=halo` and `session.id=<halo session id>`.
59
+
60
+ ### `invoke_agent <agentName>`
61
+
62
+ One per turn — the root span, opened by `beginTurn` and closed by `endTurn`.
63
+
64
+ | attribute | always | capture_content |
65
+ |---|---|---|
66
+ | `gen_ai.operation.name` | `invoke_agent` | |
67
+ | `gen_ai.agent.name` | ✓ | |
68
+ | `gen_ai.request.model` | ✓ | |
69
+ | `gen_ai.task.input` | | user message text |
70
+ | `gen_ai.task.output` | | final assistant text |
71
+ | `gen_ai.system_instructions` | | system prompt, capped 8 KB — once per turn here, not on every `chat` |
72
+
73
+ On error: status `ERROR` + the error message, plus a low-cardinality `error.type` attribute (parsed `<ErrName>Error/Exception` prefix, else `_OTHER`).
74
+
75
+ ### `chat <modelId>`
76
+
77
+ Child of `invoke_agent`, one per model call — created retroactively on the `usage` event (the agent loop only reports a call ended, with its `durationMs`).
78
+
79
+ | attribute | always | capture_content |
80
+ |---|---|---|
81
+ | `gen_ai.operation.name` | `chat` | |
82
+ | `gen_ai.request.model` | ✓ | |
83
+ | `gen_ai.usage.input_tokens` | ✓ | |
84
+ | `gen_ai.usage.output_tokens` | ✓ | |
85
+ | `gen_ai.response.finish_reasons` | `["tool_use"]` or `["end_turn"]` | |
86
+ | `gen_ai.input.messages` | | semconv JSON (see below) — **delta only**: messages appended since this turn's previous `chat` span (first call → the user message; later calls → that cycle's `tool_result`s) |
87
+ | `gen_ai.output.messages` | | semconv JSON, only the trailing assistant message |
88
+
89
+ Semconv message JSON: `[{role, parts:[{type:"text",content}|{type:"tool_call",id,name,arguments}|{type:"tool_call_response",id,result}]}]`.
90
+
91
+ `gen_ai.input.messages` is incremental on purpose (since 1.3.1): replaying the full history on every model call made a turn's exported bytes O(n²) and the system prompt alone was ~60% of the volume — a 4-turn / 9-call session dropped from 129 KB to 44 KB of content attributes. Nothing is lost: the full conversation for a trace is the concatenation of its `chat` spans' input + output deltas in order (a `TurnState.messageCursor` tracks the boundary; a mid-turn compact that shrinks the history yields an empty delta and re-syncs the cursor). Verified that AgentCore Evaluations scores are unchanged — its evaluators read `invoke_agent`'s `task.*` and `execute_tool`'s `arguments/result`, not the chat messages.
92
+
93
+ **Note**: `gen_ai.usage.input_tokens` counts only the prompt-cache MISS increment on Bedrock — small values like `2` are normal, not a bug.
94
+
95
+ ### `execute_tool <toolName>`
96
+
97
+ Child of the `chat` span that requested it, one per tool call — created on the matching `tool_result` event.
98
+
99
+ | attribute | always | capture_content |
100
+ |---|---|---|
101
+ | `gen_ai.operation.name` | `execute_tool` | |
102
+ | `gen_ai.tool.name` | ✓ | |
103
+ | `gen_ai.tool.call.id` | ✓ | |
104
+ | `gen_ai.tool.call.arguments` | | JSON |
105
+ | `gen_ai.tool.call.result` | | text, empty → `[no output]` |
106
+
107
+ Tool calls still pending when `endTurn` fires (turn interrupted/aborted mid-cycle) are closed as orphans with status `ERROR` and `halo.tool.orphaned=true`, so no trace is left with a dangling tool-call id.
108
+
109
+ All content attributes are capped (32 KB general cap, 2 KB per message part, 8 KB for system instructions); over-cap content is truncated with a `…[truncated N chars]` marker, and an over-cap message list drops the oldest messages first with an omission marker.
110
+
111
+ ## Metrics
112
+
113
+ Exported every 15s via `PeriodicExportingMetricReader`. Same scope as the spans.
114
+
115
+ | name | kind | unit | attrs |
116
+ |---|---|---|---|
117
+ | `gen_ai.client.token.usage` | histogram | `{token}` | `gen_ai.request.model`, `gen_ai.token.type=input\|output` |
118
+ | `gen_ai.client.operation.duration` | histogram | s | `gen_ai.request.model`, `gen_ai.operation.name=chat` |
119
+ | `halo.tool.duration` | histogram | s | `gen_ai.tool.name` |
120
+ | `halo.turn.duration` | histogram | s | `outcome=ok\|error` |
121
+ | `halo.model.retries` | counter | — | `kind=<retry kind>` |
122
+
123
+ `halo.model.retries` is incremented by `recordRetry(kind)` from 7 retry sites in `session-manager.ts`: `context_overflow`, `throttle`, `server_error`, `network`, `empty_response`, `corrupted`, `multimodal_4xx`.
124
+
125
+ ## Logs bridge
126
+
127
+ Every `logger.*` call in `packages/server/src/logger.ts` also emits an OTel LogRecord when `enabled`: severity number `debug=5 / info=9 / warn=13 / error=17`, attribute `halo.module` set to the call's `[ModuleName]` prefix. Same OTLP endpoint, `/v1/logs`.
128
+
129
+ ## Reference: collector → AWS CloudWatch / X-Ray
130
+
131
+ Verified working, all AWS-specific pieces live in the collector config, not in halo. `otel/opentelemetry-collector-contrib:0.161.0`:
132
+
133
+ ```yaml
134
+ extensions:
135
+ sigv4auth/xray:
136
+ region: <region>
137
+ service: xray
138
+ sigv4auth/logs:
139
+ region: <region>
140
+ service: logs
141
+
142
+ processors:
143
+ resource:
144
+ attributes:
145
+ - { key: aws.service.type, value: gen_ai_agent, action: insert }
146
+ - { key: aws.log.group.names, value: /aws/bedrock-agentcore/runtimes/<name>, action: insert }
147
+
148
+ exporters:
149
+ otlphttp/xray:
150
+ endpoint: https://xray.<region>.amazonaws.com/v1/traces
151
+ auth: { authenticator: sigv4auth/xray }
152
+ otlphttp/cwlogs:
153
+ endpoint: https://logs.<region>.amazonaws.com/v1/logs
154
+ auth: { authenticator: sigv4auth/logs }
155
+ headers:
156
+ x-aws-log-group: /aws/bedrock-agentcore/runtimes/<name>
157
+ x-aws-log-stream: runtime-logs
158
+ awsemf:
159
+ namespace: bedrock-agentcore
160
+ ```
161
+
162
+ Prerequisites: CloudWatch Transaction Search must be enabled (spans land in the shared `aws/spans` log group, no resource policy needed); the log group `/aws/bedrock-agentcore/runtimes/<name>` must be created beforehand.
163
+
164
+ Halo-side settings for this setup: `endpoint=http://localhost:4318`, `service_name=<name>`, `capture_content=true` (AgentCore Evaluations needs content to score against).
165
+
166
+ ## AgentCore Evaluations
167
+
168
+ Two modes, both read `aws/spans` filtered by data source `serviceNames: ["<service_name>"]`:
169
+
170
+ 1. **Online evaluation config** (console: AgentCore → Evaluations, or `create-online-evaluation-config`): data source `aws/spans` + serviceNames filter, evaluators e.g. `Builtin.Helpfulness` / `Builtin.GoalSuccessRate` / `Builtin.ToolSelectionAccuracy`, results written to `/aws/bedrock-agentcore/evaluations/results/<config>`. Results appear roughly **15 minutes** after the session goes idle (`sessionTimeoutMinutes` idle detection + a processing queue).
171
+ 2. **On-demand `aws bedrock-agentcore evaluate`** — synchronous, result only in the response, not persisted. Input: `{"sessionSpans":[<span doc>, …]}`, each span doc the flattened OTLP shape CloudWatch itself writes into `aws/spans`:
172
+
173
+ ```jsonc
174
+ {
175
+ "resource": { "attributes": { "service.name": "halo" } },
176
+ "scope": { "name": "opentelemetry.instrumentation.halo", "version": "1" },
177
+ "traceId": "...", "spanId": "...", "parentSpanId": "...",
178
+ "name": "chat <modelId>",
179
+ "kind": "INTERNAL",
180
+ "startTimeUnixNano": "...", "endTimeUnixNano": "...", "durationNano": "...",
181
+ "attributes": { "gen_ai.operation.name": "chat", "...": "..." },
182
+ "status": { "code": "OK" }
183
+ }
184
+ ```
185
+
186
+ Target by evaluator level:
187
+
188
+ | evaluator level | example | `--evaluation-target` |
189
+ |---|---|---|
190
+ | SESSION | `GoalSuccessRate` | omit — feed the whole session |
191
+ | TRACE | `Helpfulness`, `Correctness` | `{"traceIds":[…]}` |
192
+ | TOOL_CALL | `ToolSelectionAccuracy` | `{"spanIds":[…]}` (passing traceIds → `ValidationException`) |
193
+
194
+ Verified feeding the input **directly from halo's own OTLP output** (collector `file` exporter → flatten), no CloudWatch involved: `GoalSuccessRate` 1.0, `Helpfulness` 1.0, `ToolSelectionAccuracy` 1.0.
195
+
196
+ **Contract for custom instrumentation** (official: [supported-frameworks-generic](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/supported-frameworks-generic.html), plus 11 probe runs):
197
+
198
+ - `scope.name` MUST start with `opentelemetry.instrumentation.*` (or `openinference.instrumentation.*`) — a custom scope like `halo.tracing` → `no spans with supported scope` (the error message only lists named frameworks; the generic prefix rule still applies).
199
+ - Every span MUST carry `gen_ai.operation.name` ∈ `{invoke_agent, chat, execute_tool}`; spans without it are skipped (`no spans to evaluate`).
200
+ - Content is read from: `invoke_agent`'s `gen_ai.task.input` / `.task.output` (falls back to the first user / last assistant message on `chat` spans); `chat`'s `gen_ai.input.messages` / `.output.messages` / `gen_ai.system_instructions`; `execute_tool`'s `gen_ai.tool.name` / `.tool.call.arguments` / `.tool.call.result`. Values are stringified, not parsed.
201
+ - `session.id` groups spans into a session — required for SESSION-level evaluators.
202
+ - The parser requires `traceId`, `spanId`, `startTimeUnixNano`, `endTimeUnixNano`. `resource`, `scope.version`, `kind`, `status`, `name`, `durationNano`, `parentSpanId` are all optional — a single-span minimal doc scored fine.
203
+ - Strict on keys, lenient on values: garbage attributes with a correct envelope → rejected; correct keys with any string content → scored.
204
+ - `gen_ai.tool.definitions` (JSON array of tool schemas on `chat` spans) is read by `GoalSuccessRate` to check called tools were declared; halo does not emit it and still scored 1.0 — optional.
205
+
206
+ The official spec is spread over three places: the CLI/API reference (envelope only — `sessionSpans` is a list of opaque `document`s, 1–20000), the generic-framework page (semantic contract), and the runtime's own error messages (structural field requirements). None of the three alone is sufficient.
207
+
208
+ References: [CLI reference](https://docs.aws.amazon.com/cli/latest/reference/bedrock-agentcore/evaluate.html) · [API reference](https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_Evaluate.html) · [on-demand guide](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/getting-started-on-demand.html)
209
+
210
+ ## Rejected alternatives
211
+
212
+ - **Direct CloudWatch / X-Ray SDK calls from halo** (an earlier 2026-08 prototype) — vendor lock-in inside core; replaced by OTel + collector.
213
+ - **ADOT `--require` into the halo process** — pulls in `sdk-node` + gRPC + auto-instrumentations, and its `api-logs` 0.219 is incompatible with halo's 0.222 (0.x minors must match); also couples the process to one vendor's distro. Rejected. The `??=` env mapping + external-SDK detection still let an operator do this if they insist.
214
+ - **gRPC exporter** — an extra native-ish dependency (`@grpc/grpc-js`) for a port (4317) that every collector also serves over plain HTTP (4318).
@@ -181,6 +181,8 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
181
181
 
182
182
  **runSession loop** (`runSession(sessionId, message)`, `message: string | ContentBlock[]`): runs the opening turn when there is one, then drains. An empty **string** message means "the work is already in `messageQueue`" (the `querySession` idle path) — it skips the opening turn and goes straight to drain. The per-turn reset (fresh `toolCallLog` / `warnedToolHashes`, `interruptRequested = false`) runs before the opening turn; `drainQueue` repeats the same reset before each merged batch so a stale interrupt flag never leaks across turns. Server-only: right before `session.promise = runFn()` it inserts the session id into the run ledger, and in the `finally` (right after `session.promise = null`, before emitting `complete`) it deletes it — see [Run ledger](#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb) above.
183
183
 
184
+ **Observability.** `runAgentTurn` is wrapped by `beginTurn` / `endTurn` from `observability/genai-spans.ts` — one `invoke_agent` span per turn, with `chat` / `execute_tool` child spans created from the `onAgentEvent` hook inside the event loop; see [design/observability.md](observability.md).
185
+
184
186
  **Arrival stamp.** Both paths run the turn through `runAgentTurn`, which prepends `[<ISO-8601 UTC>] ` (e.g. `[2026-09-12T15:17:44.153Z] `) to the first text block of the model-facing input — an image-only input gets a stamp-only text block unshifted. The model has no clock; without this it couldn't tell a reply that came two days later from one that came instantly, or how long a sub-agent report took to land. Stamped **once, before the retry loop**, so every attempt (and the [multimodal 4xx degrade](#multimodal-4xx-degrade) rebuild) reuses the identical input. Agent reports therefore read `[<iso>] (from: session X)\n…` — stamp outermost, no special-casing. Only `agent.messages` changes: the UI `type:'user'` event keeps the raw text (see [Turn matching](#exchange-deletion-soft-ui--hard-raw) for the strip on compare), and the self-compact instruction / `[Conversation Summary]` injection / `tool_result` messages are never stamped.
185
187
 
186
188
  **Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent` / `deliverGoalRound`) is the same accumulation on the server side.
@@ -387,6 +387,10 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
387
387
  | `general.sandbox.hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh` | bwrap tmpfs overlay (Linux only) |
388
388
  | `general.sandbox.hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm` | bwrap /dev/null bind (Linux only) |
389
389
  | `general.logging.level` | warn | `debug` / `info` / `warn` / `error` |
390
+ | `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required. See [observability.md](observability.md) |
391
+ | `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required |
392
+ | `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required |
393
+ | `general.observability.capture_content` | `false` | Put prompt/completion/tool text on spans. Restart required |
390
394
 
391
395
  Schema source: [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`.
392
396
 
@@ -355,6 +355,15 @@ File: `packages/server/src/routes/settings.ts`
355
355
 
356
356
  PATCH / DELETE walk the dotted key layer by layer, so a segment of `__proto__` / `constructor` / `prototype` is rejected with 400 `Invalid key` — the leaf assignment would otherwise land on `Object.prototype` and pollute every object in the process.
357
357
 
358
+ ### Observability settings
359
+
360
+ - `general.observability.endpoint` — OTLP collector base URL (e.g. `http://localhost:4318`); empty = off
361
+ - `general.observability.service_name` — OTel resource `service.name`, default `halo`
362
+ - `general.observability.headers` — extra OTLP request headers, comma-separated `k=v` (secret)
363
+ - `general.observability.capture_content` — put prompt/completion/tool text on spans, default `false`
364
+
365
+ All four are `globalOnly`, take effect on restart. See [design/observability.md](../design/observability.md).
366
+
358
367
  ## Metrics
359
368
 
360
369
  File: `packages/server/src/routes/metrics.ts`.
@@ -228,7 +228,27 @@ EXPOSE 9527
228
228
  CMD halo setup -y && halo server start
229
229
  ```
230
230
 
231
- ## 9. Optional: Nginx reverse proxy
231
+ ## 9. Observability (OpenTelemetry collector)
232
+
233
+ Halo exports traces, metrics and logs over OTLP http/protobuf when `general.observability.endpoint` is set — off by default, no vendor code in the server itself. Point it at any OpenTelemetry collector:
234
+
235
+ ```bash
236
+ # settings.yaml (global scope) or via the admin Settings page
237
+ general:
238
+ observability:
239
+ endpoint: http://localhost:4318 # collector's OTLP/HTTP port, NOT 4317 (gRPC, unsupported)
240
+ service_name: halo
241
+ headers: '' # e.g. authorization=Bearer <token> for a hosted backend
242
+ capture_content: false # true to include prompt/completion/tool text on spans
243
+ ```
244
+
245
+ All four keys take effect on restart: `halo server restart`.
246
+
247
+ For a hosted backend (Honeycomb, Grafana Cloud, etc.) use its `https://` OTLP endpoint and put its auth token in `headers` — TLS with CA-signed certs works out of the box. Self-signed / mTLS collectors need the standard `OTEL_EXPORTER_OTLP_CERTIFICATE` / `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` / `OTEL_EXPORTER_OTLP_CLIENT_KEY` env vars.
248
+
249
+ For routing into AWS CloudWatch / X-Ray (sigv4auth collector config, AgentCore Evaluations) see [design/observability.md](../design/observability.md).
250
+
251
+ ## 10. Optional: Nginx reverse proxy
232
252
 
233
253
  ```nginx
234
254
  server {
@@ -128,6 +128,10 @@ settings.yaml only (no env override):
128
128
  - `general.sandbox.writable_dirs` (default empty) — dirs bind-mounted read-write inside the bwrap sandbox, for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions, scope: global
129
129
  - `general.sandbox.hidden_files` (default `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files) — bwrap /dev/null binds, scope: global
130
130
  - `general.logging.level` (default `warn`) — log level: debug | info | warn | error
131
+ - `general.observability.endpoint` (default `''`) — OTLP collector base URL (e.g. `http://localhost:4318`); empty = off, scope: global, restart required. See [design/observability.md](../design/observability.md)
132
+ - `general.observability.service_name` (default `halo`) — OTel resource `service.name`, scope: global, restart required
133
+ - `general.observability.headers` (default `''`, secret) — extra OTLP request headers, comma-separated `k=v`, scope: global, restart required
134
+ - `general.observability.capture_content` (default `false`) — put prompt/completion/tool text on spans, scope: global, restart required
131
135
 
132
136
  `<provider-id>.secrets.*` (server-only, hard-rejected by `{{}}` substitution; declared in `models/<provider-id>.yaml` `secrets:`):
133
137
  - `aws-bedrock-claude-invoke.secrets.access_key_id` / `.secret_access_key` — AWS / Bedrock credentials
@@ -103,6 +103,7 @@ Common settings:
103
103
  - `general.session.max_nesting_depth` — max session nesting depth
104
104
  - `general.compact.keep_messages` — recent messages kept intact during compaction
105
105
  - `general.logging.level` — `debug` / `info` / `warn` / `error`
106
+ - `general.observability.endpoint` — OTLP collector URL; leave empty to keep observability off (see [design/observability.md](../design/observability.md))
106
107
  - `<provider-id>.secrets.api_key` — provider credentials (Kimi / DeepSeek bearer token)
107
108
  - `<provider-id>.secrets.access_key_id` / `.secret_access_key` — AWS Bedrock
108
109
  - `<skill-id>.params.<key>` — values an agent can inject into its own `shell_exec` via `{{<skill-id>.params.<key>}}`