@llm4ts/runner 2.29.0 → 2.30.0

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.
package/dist/Otel.js ADDED
@@ -0,0 +1,140 @@
1
+ // OpenTelemetry export (ADR 0026): Effect's own OTLP tracer and metrics
2
+ // exporters over core's fetch client, so a run can show up in Phoenix,
3
+ // Langfuse, SigNoz or any collector with no SDK and no new dependency.
4
+ // The standard OTEL_* variables are the truth; `--otel` is the laptop case.
5
+ import * as ConfigProvider from "effect/ConfigProvider";
6
+ import * as Duration from "effect/Duration";
7
+ import * as Effect from "effect/Effect";
8
+ import * as Layer from "effect/Layer";
9
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
10
+ import * as OtlpExporter from "effect/observability/OtlpExporter";
11
+ import * as OtlpMetrics from "effect/observability/OtlpMetrics";
12
+ import * as OtlpSerialization from "effect/observability/OtlpSerialization";
13
+ import * as OtlpTracer from "effect/observability/OtlpTracer";
14
+ import { packageVersion } from "./Package.js";
15
+ export const phoenixTracesUrl = "http://localhost:6006/v1/traces";
16
+ const truthy = (value) => /^(on|1|true|yes)$/iu.test(value?.trim() ?? "");
17
+ const falsy = (value) => /^(off|0|false|no)$/iu.test(value?.trim() ?? "");
18
+ /**
19
+ * Off unless asked. An OTLP endpoint in the environment wins and is used as
20
+ * the standard variables say; `LLM4TS_OTEL=on` alone means a local Phoenix,
21
+ * traces only. `OTEL_SDK_DISABLED` or `LLM4TS_OTEL=off` switch everything off.
22
+ */
23
+ export const otelConfig = (environment) => {
24
+ if (truthy(environment.OTEL_SDK_DISABLED) || falsy(environment.LLM4TS_OTEL)) {
25
+ return { mode: "off" };
26
+ }
27
+ // Under the test runner the exporter's sleeps run on a TestClock nobody
28
+ // advances, so its final flush would wait forever: a developer with
29
+ // LLM4TS_OTEL=on in the shell must still get a green suite.
30
+ if (environment.VITEST !== undefined) {
31
+ return { mode: "off" };
32
+ }
33
+ const endpoint = environment.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim() ||
34
+ environment.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
35
+ if (endpoint !== undefined && endpoint.length > 0) {
36
+ return { mode: "env", endpoint };
37
+ }
38
+ return truthy(environment.LLM4TS_OTEL)
39
+ ? { mode: "phoenix", tracesUrl: phoenixTracesUrl }
40
+ : { mode: "off" };
41
+ };
42
+ /** The run end may wait this long for the last batch; the event drain waits the same. */
43
+ export const flushTimeout = Duration.seconds(3);
44
+ /** This runner's version, for `service.version`. */
45
+ export const runnerVersion = packageVersion;
46
+ const resourceOf = (resource) => ({
47
+ serviceName: "llm4ts",
48
+ serviceVersion: resource.serviceVersion,
49
+ attributes: { "openinference.project.name": resource.project }
50
+ });
51
+ /**
52
+ * The exporter for `config`: nothing when off; traces only for Phoenix;
53
+ * traces and metrics from the standard variables for a generic endpoint.
54
+ * Logs are never exported. Export errors are the exporter's to swallow; the
55
+ * run never fails on them.
56
+ */
57
+ export const otelLayer = (config, resource,
58
+ /** What the standard variables are read from in env mode (the run's environment). */
59
+ environment = process.env) => {
60
+ switch (config.mode) {
61
+ case "off":
62
+ // The exporter's no-op flusher: nothing to export, nothing to flush.
63
+ return OtlpExporter.layerFlusher;
64
+ case "phoenix":
65
+ return OtlpTracer.layer({
66
+ url: config.tracesUrl,
67
+ resource: resourceOf(resource),
68
+ shutdownTimeout: flushTimeout
69
+ }).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer));
70
+ case "env":
71
+ // The standard variables are read from the run's own environment, with
72
+ // the two exporter defaults an endpoint implies; the process env is
73
+ // never written.
74
+ return Layer.mergeAll(OtlpTracer.layerFromConfig({ resource: resourceOf(resource) }), OtlpMetrics.layerFromConfig({ resource: resourceOf(resource) })).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer), Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord({
75
+ ...environment,
76
+ ...otelEnvironmentDefaults(config, environment)
77
+ }))));
78
+ }
79
+ };
80
+ /**
81
+ * `layerFromConfig` exports nothing unless `OTEL_TRACES_EXPORTER` names
82
+ * `otlp`; an endpoint in the environment is the operator's intent, so the
83
+ * layer reads its variables with that default (and the metrics one) added
84
+ * when they are unset.
85
+ */
86
+ export const otelEnvironmentDefaults = (config, environment) => config.mode !== "env"
87
+ ? {}
88
+ : {
89
+ ...(environment.OTEL_TRACES_EXPORTER === undefined ? { OTEL_TRACES_EXPORTER: "otlp" } : {}),
90
+ ...(environment.OTEL_METRICS_EXPORTER === undefined
91
+ ? { OTEL_METRICS_EXPORTER: "otlp" }
92
+ : {})
93
+ };
94
+ export const otelSummary = (config) => {
95
+ switch (config.mode) {
96
+ case "off":
97
+ return undefined;
98
+ case "phoenix":
99
+ return `otel → Phoenix at ${config.tracesUrl} (traces)`;
100
+ case "env":
101
+ return `otel → ${config.endpoint} (traces + metrics, OTEL_* variables)`;
102
+ }
103
+ };
104
+ /**
105
+ * The one warning a run prints when export is on and nothing answers at the
106
+ * endpoint (spec §9): the exporter itself only logs at debug level and drops
107
+ * the batch. Nothing when off or when the endpoint answered.
108
+ */
109
+ export const otelWarning = (config, answers) => {
110
+ if (config.mode === "off" || answers !== false) {
111
+ return undefined;
112
+ }
113
+ const where = config.mode === "phoenix" ? config.tracesUrl : config.endpoint;
114
+ return (`⚠ otel: nothing answers at ${where} — spans will be dropped (the run is unaffected)` +
115
+ (config.mode === "phoenix"
116
+ ? "; start Phoenix with `docker run -p 6006:6006 arizephoenix/phoenix:latest`"
117
+ : ""));
118
+ };
119
+ /** The doctor's line: `answers` is the probe's result, undefined when export is off. */
120
+ export const otelDoctorLine = (config, answers) => {
121
+ switch (config.mode) {
122
+ case "off":
123
+ return "otel: off (LLM4TS_OTEL=on for a local Phoenix, or OTEL_EXPORTER_OTLP_ENDPOINT)";
124
+ case "phoenix":
125
+ case "env": {
126
+ const where = config.mode === "phoenix" ? `Phoenix at ${config.tracesUrl}` : config.endpoint;
127
+ return `otel: ${where} — ${answers === true
128
+ ? "endpoint answers"
129
+ : "endpoint does not answer (spans will be dropped, the run is unaffected)"}`;
130
+ }
131
+ }
132
+ };
133
+ /** Whether anything listens at the endpoint: any HTTP answer counts, a refused connection does not. */
134
+ export const probeOtelEndpoint = (config) => config.mode === "off"
135
+ ? Effect.succeed(undefined)
136
+ : Effect.tryPromise(() => fetch(config.mode === "phoenix" ? config.tracesUrl : config.endpoint, {
137
+ method: "OPTIONS",
138
+ signal: AbortSignal.timeout(1_500)
139
+ })).pipe(Effect.as(true), Effect.orElseSucceed(() => false));
140
+ //# sourceMappingURL=Otel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Otel.js","sourceRoot":"","sources":["../src/Otel.ts"],"names":[],"mappings":"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"}
package/dist/Package.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export declare const packageName = "@llm4ts/runner";
2
+ export declare const packageVersion: string;
2
3
  //# sourceMappingURL=Package.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Package.d.ts","sourceRoot":"","sources":["../src/Package.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,mBAAmB,CAAA"}
1
+ {"version":3,"file":"Package.d.ts","sourceRoot":"","sources":["../src/Package.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,WAAW,mBAAmB,CAAA;AAC3C,eAAO,MAAM,cAAc,EAAE,MAAyB,CAAA"}
package/dist/Package.js CHANGED
@@ -1,2 +1,7 @@
1
+ import { createRequire } from "node:module";
2
+ import * as Schema from "effect/Schema";
3
+ const Manifest = Schema.Struct({ version: Schema.String });
4
+ const manifest = Schema.decodeUnknownSync(Manifest)(createRequire(import.meta.url)("../package.json"));
1
5
  export const packageName = "@llm4ts/runner";
6
+ export const packageVersion = manifest.version;
2
7
  //# sourceMappingURL=Package.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"Package.js","sourceRoot":"","sources":["../src/Package.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,WAAW,GAAG,gBAAgB,CAAA"}
1
+ {"version":3,"file":"Package.js","sourceRoot":"","sources":["../src/Package.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAEvC,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAA;AAE1D,MAAM,QAAQ,GAAG,MAAM,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CACjD,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,iBAAiB,CAAC,CAClD,CAAA;AAED,MAAM,CAAC,MAAM,WAAW,GAAG,gBAAgB,CAAA;AAC3C,MAAM,CAAC,MAAM,cAAc,GAAW,QAAQ,CAAC,OAAO,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/runner",
3
- "version": "2.29.0",
3
+ "version": "2.30.0",
4
4
  "description": "Node runner, CLI, terminal, and MCP composition for llm4ts",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,6 +33,7 @@
33
33
  "./NodeProcessExecutor": "./dist/NodeProcessExecutor.js",
34
34
  "./NodeTemporaryFiles": "./dist/NodeTemporaryFiles.js",
35
35
  "./NodeWorkspace": "./dist/NodeWorkspace.js",
36
+ "./Otel": "./dist/Otel.js",
36
37
  "./NodeRuntime": "./dist/NodeRuntime.js",
37
38
  "./Packs": "./dist/Packs.js",
38
39
  "./Profile": "./dist/Profile.js",
@@ -68,8 +69,8 @@
68
69
  "typescript"
69
70
  ],
70
71
  "dependencies": {
71
- "@llm4ts/core": "2.29.0",
72
- "@llm4ts/flow": "2.29.0"
72
+ "@llm4ts/core": "2.30.0",
73
+ "@llm4ts/flow": "2.30.0"
73
74
  },
74
75
  "peerDependencies": {
75
76
  "effect": "4.0.0"
package/src/Doctor.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  geminiBridgePort as bridgePort,
12
12
  piModelsConfig as parsePiModels
13
13
  } from "./PiModels.ts"
14
+ import { otelConfig, otelDoctorLine, probeOtelEndpoint, type OtelConfig } from "./Otel.ts"
14
15
 
15
16
  const credentialKeys = [
16
17
  "OPENAI_API_KEY",
@@ -220,65 +221,75 @@ export const makeDoctorProgram = (
220
221
  nodePlainFileStore,
221
222
  process.cwd(),
222
223
  environment
223
- )
224
- ): Effect.Effect<string> =>
225
- Effect.map(Effect.all([registry.healthCheckAll, nodeCheck]), ([statuses, node]) => {
226
- const lines: Array<string> = []
227
- lines.push("llm4ts doctor")
228
- lines.push("")
229
- lines.push("connectors:")
230
- const sorted = Array.from(statuses.entries()).sort(([a], [b]) => a.value.localeCompare(b.value))
231
- for (const [id, status] of sorted) {
232
- lines.push(statusLine(id.value, status))
233
- }
234
- lines.push("")
235
- lines.push("credentials:")
236
- for (const key of credentialKeys) {
237
- const value = environment[key]
238
- lines.push(` ${value === undefined || value.length === 0 ? "✖" : "✔"} ${key}`)
239
- }
240
- lines.push("")
241
- lines.push("gates:")
242
- lines.push(
243
- ...wrap(
244
- `${node._tag === "Mismatch" || node._tag === "NoNode" ? "✖" : "✔"} ${node.summary}`,
245
- 78,
246
- " "
247
- )
248
- )
249
-
250
- // Connector prerequisites the environment must carry BEFORE a run: shown
251
- // when the connector is selected or the machine has its CLI, because that
252
- // is exactly when a missing one turns into a confusing mid-run failure.
253
- const coder = selectedCoder(environment)
254
- const geminiRelevant =
255
- coder.startsWith("gemini") ||
256
- Array.from(statuses.keys()).some(
257
- (id) =>
258
- id.value === ConnectorIds.GeminiCli.value || id.value === ConnectorIds.GeminiApi.value
224
+ ),
225
+ /** Whether the OTLP endpoint answers (ADR 0026); the network probe by default. */
226
+ probeOtel: (config: OtelConfig) => Effect.Effect<boolean | undefined> = probeOtelEndpoint
227
+ ): Effect.Effect<string> => {
228
+ const otel = otelConfig(environment)
229
+ return Effect.map(
230
+ Effect.all([registry.healthCheckAll, nodeCheck, probeOtel(otel)]),
231
+ ([statuses, node, otelAnswers]) => {
232
+ const lines: Array<string> = []
233
+ lines.push("llm4ts doctor")
234
+ lines.push("")
235
+ lines.push("connectors:")
236
+ const sorted = Array.from(statuses.entries()).sort(([a], [b]) =>
237
+ a.value.localeCompare(b.value)
259
238
  )
260
- const bridge = geminiBridgePrerequisites(environment, readPiModelsJson())
261
- if (geminiRelevant || bridge !== undefined) {
239
+ for (const [id, status] of sorted) {
240
+ lines.push(statusLine(id.value, status))
241
+ }
262
242
  lines.push("")
263
- lines.push("prerequisites:")
264
- const pushReport = (label: string, report: PrerequisiteReport): void => {
265
- lines.push(` ${report.satisfied ? "✔" : "?"} ${label}: ${report.summary}`)
266
- if (report.hint !== undefined) {
267
- lines.push(...wrap(report.hint, 78, " "))
243
+ lines.push("credentials:")
244
+ for (const key of credentialKeys) {
245
+ const value = environment[key]
246
+ lines.push(` ${value === undefined || value.length === 0 ? "✖" : "✔"} ${key}`)
247
+ }
248
+ lines.push("")
249
+ lines.push("gates:")
250
+ lines.push(
251
+ ...wrap(
252
+ `${node._tag === "Mismatch" || node._tag === "NoNode" ? "✖" : "✔"} ${node.summary}`,
253
+ 78,
254
+ " "
255
+ )
256
+ )
257
+
258
+ // Connector prerequisites the environment must carry BEFORE a run: shown
259
+ // when the connector is selected or the machine has its CLI, because that
260
+ // is exactly when a missing one turns into a confusing mid-run failure.
261
+ const coder = selectedCoder(environment)
262
+ const geminiRelevant =
263
+ coder.startsWith("gemini") ||
264
+ Array.from(statuses.keys()).some(
265
+ (id) =>
266
+ id.value === ConnectorIds.GeminiCli.value || id.value === ConnectorIds.GeminiApi.value
267
+ )
268
+ const bridge = geminiBridgePrerequisites(environment, readPiModelsJson())
269
+ if (geminiRelevant || bridge !== undefined) {
270
+ lines.push("")
271
+ lines.push("prerequisites:")
272
+ const pushReport = (label: string, report: PrerequisiteReport): void => {
273
+ lines.push(` ${report.satisfied ? "✔" : "?"} ${label}: ${report.summary}`)
274
+ if (report.hint !== undefined) {
275
+ lines.push(...wrap(report.hint, 78, " "))
276
+ }
277
+ for (const line of report.detail ?? []) {
278
+ lines.push(` ${line}`)
279
+ }
268
280
  }
269
- for (const line of report.detail ?? []) {
270
- lines.push(` ${line}`)
281
+ if (geminiRelevant) {
282
+ pushReport("gemini", geminiPrerequisites(environment))
283
+ }
284
+ if (bridge !== undefined) {
285
+ pushReport("pi-gemini-bridge", bridge)
271
286
  }
272
287
  }
273
- if (geminiRelevant) {
274
- pushReport("gemini", geminiPrerequisites(environment))
275
- }
276
- if (bridge !== undefined) {
277
- pushReport("pi-gemini-bridge", bridge)
278
- }
279
- }
280
288
 
281
- lines.push("")
282
- lines.push(`coder: ${coder}`)
283
- return `${lines.join("\n")}\n`
284
- })
289
+ lines.push("")
290
+ lines.push(otelDoctorLine(otel, otelAnswers))
291
+ lines.push(`coder: ${coder}`)
292
+ return `${lines.join("\n")}\n`
293
+ }
294
+ )
295
+ }
package/src/FlowArgs.ts CHANGED
@@ -34,6 +34,8 @@ environment:
34
34
  LLM4TS_VERBOSITY terminal verbosity: quiet|normal|verbose
35
35
  LLM4TS_UI tree: the full-screen agent tree (ADR 0022; q returns to the classic view)
36
36
  LLM4TS_TRANSCRIPT on: record each seat's input and output under .llm4ts/transcripts/
37
+ LLM4TS_OTEL on: export spans over OTLP (a local Phoenix at localhost:6006 unless OTEL_* says otherwise)
38
+ LLM4TS_OTEL_CONTENT on|full: prompts, replies and tool I/O travel too (off by default)
37
39
  OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY
38
40
  credentials for API providers
39
41
 
package/src/FlowRunner.ts CHANGED
@@ -58,7 +58,18 @@ import {
58
58
  } from "@llm4ts/flow/FlowEvents"
59
59
  import { FlowContext, type ContextOptions, type FlowContextShape } from "@llm4ts/flow/FlowContext"
60
60
  import { makeFlowRecorder, type RunOutcome } from "@llm4ts/flow/FlowRecorder"
61
- import { timedJudgment, timedSeat, withTimedRole } from "@llm4ts/flow/Timing"
61
+ import { timedJudgment, timedSeat, withTimedRole, type TimedSeatOptions } from "@llm4ts/flow/Timing"
62
+ import { attr, otelContent, withKindSpan } from "@llm4ts/flow/Spans"
63
+ import * as Tracer from "effect/Tracer"
64
+ import { basename } from "node:path"
65
+ import {
66
+ otelConfig,
67
+ otelWarning,
68
+ probeOtelEndpoint,
69
+ otelLayer,
70
+ otelSummary,
71
+ runnerVersion
72
+ } from "./Otel.ts"
62
73
  import { transcriptSeat, type TranscriptSink } from "@llm4ts/flow/Transcript"
63
74
  import { nodeTranscriptFiles, nodeTranscriptSink, transcriptsWanted } from "./Transcripts.ts"
64
75
  import { idleAfterFrom } from "./AgentTree.ts"
@@ -362,6 +373,13 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
362
373
  const runEnvironment = options.environment ?? process.env
363
374
  const estimateOptions = estimatedUsageOptionsFromEnv(runEnvironment)
364
375
  const meterUsage = options.estimateUsage ?? !switchedOff(runEnvironment.LLM4TS_ESTIMATE_USAGE)
376
+ // Every seat's spans (ADR 0026): content only when asked, an estimate on a
377
+ // call the backend reports no usage for, and who served it when known.
378
+ const seatOptions = (executor?: Effect.Effect<string | undefined>): TimedSeatOptions => ({
379
+ content: otelContent(runEnvironment),
380
+ ...(meterUsage ? { estimate: estimateOptions } : {}),
381
+ ...(executor === undefined ? {} : { executor })
382
+ })
365
383
  const metered = (service: LlmServiceShape): Effect.Effect<LlmServiceShape> =>
366
384
  meterUsage
367
385
  ? Effect.map(makeEstimatedUsageMeter(service, estimateOptions), (meter) => meter.service)
@@ -490,18 +508,26 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
490
508
  ): FlowContextShape =>
491
509
  FlowContext.of({
492
510
  // Every call is timed on the lane's events, under its role.
493
- reasoning: recorded(timedSeat(seats.reasoning, laneEvents, "reasoning"), lane, "reasoning"),
511
+ reasoning: recorded(
512
+ timedSeat(seats.reasoning, laneEvents, "reasoning", seatOptions()),
513
+ lane,
514
+ "reasoning"
515
+ ),
494
516
  coder: {
495
517
  ...seats.coder,
496
- ...recorded(timedSeat(seats.coder, laneEvents, "coder"), lane, "coder")
518
+ ...recorded(timedSeat(seats.coder, laneEvents, "coder", seatOptions()), lane, "coder")
497
519
  },
498
520
  judgment: timedJudgment(seats.judgment, laneEvents),
499
- judge: recorded(timedSeat(seats.judge, laneEvents, "judgment"), lane, "judgment"),
521
+ judge: recorded(
522
+ timedSeat(seats.judge, laneEvents, "judgment", seatOptions()),
523
+ lane,
524
+ "judgment"
525
+ ),
500
526
  git: makeGitTool(dependencies.process, workDir, laneEvents),
501
527
  hosting: makeGitHubTool(dependencies.process, workDir, laneEvents),
502
528
  events: laneEvents,
503
529
  reviewers: seats.reviewers.map((reviewer) =>
504
- recorded(timedSeat(reviewer, laneEvents, "reviewer"), lane, "reviewer")
530
+ recorded(timedSeat(reviewer, laneEvents, "reviewer", seatOptions()), lane, "reviewer")
505
531
  ),
506
532
  coderCapabilities: seats.coder.capabilities,
507
533
  userPrompt: options.userPrompt,
@@ -671,7 +697,8 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
671
697
  label
672
698
  }),
673
699
  laneEvents,
674
- role
700
+ role,
701
+ seatOptions()
675
702
  ),
676
703
  lane,
677
704
  role
@@ -709,7 +736,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
709
736
  return FlowContext.of({
710
737
  reasoning: view.forRole(rebind ? "planner" : "reviewer"),
711
738
  coder: recorded(
712
- timedSeat(held.service, laneEvents, "coder"),
739
+ timedSeat(held.service, laneEvents, "coder", seatOptions(held.executor)),
713
740
  lane,
714
741
  "coder",
715
742
  held.executor
@@ -811,6 +838,9 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
811
838
  const environment = options.environment ?? process.env
812
839
  const verbosity = options.verbosity ?? "Normal"
813
840
  const persistRun = options.persistRun ?? true
841
+ // OpenTelemetry export (ADR 0026): decided from the environment, built once
842
+ // per run, flushed when the run's scope closes.
843
+ const otel = otelConfig(environment)
814
844
  const tracePath =
815
845
  options.tracePath ??
816
846
  (persistRun ? join(options.workDir, ".llm4ts", `trace-${startedAt}.jsonl`) : undefined)
@@ -867,6 +897,16 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
867
897
  )
868
898
  )
869
899
  }
900
+ const where = otelSummary(otel)
901
+ if (where !== undefined) {
902
+ yield* surface.log(palette.info(where))
903
+ // One warning, at the start, when nothing listens: the exporter itself
904
+ // drops batches quietly. The probe is bounded (1.5s) and runs once.
905
+ const warning = otelWarning(otel, yield* probeOtelEndpoint(otel))
906
+ if (warning !== undefined) {
907
+ yield* surface.log(palette.warn(warning))
908
+ }
909
+ }
870
910
  }
871
911
  const recorder =
872
912
  tracePath === undefined
@@ -925,7 +965,32 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
925
965
  tracePath === undefined
926
966
  ? bundle.context
927
967
  : { ...bundle.context, trace: { runId: bundle.runId, path: tracePath } }
928
- return yield* body(context).pipe(
968
+ const runAttributes: Record<string, unknown> = {
969
+ [attr.run]: bundle.runId,
970
+ [attr.session]: bundle.runId,
971
+ ...(environment.LLM4TS_FLOW === undefined ? {} : { [attr.flow]: environment.LLM4TS_FLOW })
972
+ }
973
+ // The run is a CHAIN span every other span descends from or links to; its
974
+ // attributes ride on all of them. Effect's own function spans stay
975
+ // unsampled so an exported trace shows the flow, not the call stack.
976
+ const traced = withKindSpan(
977
+ "run",
978
+ { kind: "CHAIN", attributes: runAttributes },
979
+ Object.entries(runAttributes).reduce(
980
+ (acc, [key, value]) => Effect.annotateSpans(key, value)(acc),
981
+ body(context)
982
+ )
983
+ ).pipe(
984
+ Effect.provideService(Tracer.MinimumTraceLevel, otel.mode === "off" ? "All" : "None"),
985
+ Effect.provide(
986
+ otelLayer(
987
+ otel,
988
+ { serviceVersion: runnerVersion, project: basename(options.workDir) },
989
+ environment
990
+ )
991
+ )
992
+ )
993
+ return yield* traced.pipe(
929
994
  Effect.provideService(FlowContext, context),
930
995
  Effect.andThen((value) => Effect.as(enforceBudget, value)),
931
996
  Effect.ensuring(