@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/src/Otel.ts ADDED
@@ -0,0 +1,198 @@
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.ts"
15
+
16
+ export type OtelConfig =
17
+ | { readonly mode: "off" }
18
+ | { readonly mode: "phoenix"; readonly tracesUrl: string }
19
+ | { readonly mode: "env"; readonly endpoint: string }
20
+
21
+ export const phoenixTracesUrl = "http://localhost:6006/v1/traces"
22
+
23
+ const truthy = (value: string | undefined): boolean =>
24
+ /^(on|1|true|yes)$/iu.test(value?.trim() ?? "")
25
+ const falsy = (value: string | undefined): boolean =>
26
+ /^(off|0|false|no)$/iu.test(value?.trim() ?? "")
27
+
28
+ /**
29
+ * Off unless asked. An OTLP endpoint in the environment wins and is used as
30
+ * the standard variables say; `LLM4TS_OTEL=on` alone means a local Phoenix,
31
+ * traces only. `OTEL_SDK_DISABLED` or `LLM4TS_OTEL=off` switch everything off.
32
+ */
33
+ export const otelConfig = (
34
+ environment: Readonly<Record<string, string | undefined>>
35
+ ): OtelConfig => {
36
+ if (truthy(environment.OTEL_SDK_DISABLED) || falsy(environment.LLM4TS_OTEL)) {
37
+ return { mode: "off" }
38
+ }
39
+ // Under the test runner the exporter's sleeps run on a TestClock nobody
40
+ // advances, so its final flush would wait forever: a developer with
41
+ // LLM4TS_OTEL=on in the shell must still get a green suite.
42
+ if (environment.VITEST !== undefined) {
43
+ return { mode: "off" }
44
+ }
45
+ const endpoint =
46
+ environment.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim() ||
47
+ environment.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()
48
+ if (endpoint !== undefined && endpoint.length > 0) {
49
+ return { mode: "env", endpoint }
50
+ }
51
+ return truthy(environment.LLM4TS_OTEL)
52
+ ? { mode: "phoenix", tracesUrl: phoenixTracesUrl }
53
+ : { mode: "off" }
54
+ }
55
+
56
+ export interface OtelResource {
57
+ readonly serviceVersion: string
58
+ /** Phoenix's project: the repository's basename, never a path. */
59
+ readonly project: string
60
+ }
61
+
62
+ /** The run end may wait this long for the last batch; the event drain waits the same. */
63
+ export const flushTimeout = Duration.seconds(3)
64
+
65
+ /** This runner's version, for `service.version`. */
66
+ export const runnerVersion: string = packageVersion
67
+
68
+ const resourceOf = (resource: OtelResource) => ({
69
+ serviceName: "llm4ts",
70
+ serviceVersion: resource.serviceVersion,
71
+ attributes: { "openinference.project.name": resource.project }
72
+ })
73
+
74
+ /**
75
+ * The exporter for `config`: nothing when off; traces only for Phoenix;
76
+ * traces and metrics from the standard variables for a generic endpoint.
77
+ * Logs are never exported. Export errors are the exporter's to swallow; the
78
+ * run never fails on them.
79
+ */
80
+ export const otelLayer = (
81
+ config: OtelConfig,
82
+ resource: OtelResource,
83
+ /** What the standard variables are read from in env mode (the run's environment). */
84
+ environment: Readonly<Record<string, string | undefined>> = process.env
85
+ ): Layer.Layer<OtlpExporter.Flusher> => {
86
+ switch (config.mode) {
87
+ case "off":
88
+ // The exporter's no-op flusher: nothing to export, nothing to flush.
89
+ return OtlpExporter.layerFlusher
90
+ case "phoenix":
91
+ return OtlpTracer.layer({
92
+ url: config.tracesUrl,
93
+ resource: resourceOf(resource),
94
+ shutdownTimeout: flushTimeout
95
+ }).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
96
+ case "env":
97
+ // The standard variables are read from the run's own environment, with
98
+ // the two exporter defaults an endpoint implies; the process env is
99
+ // never written.
100
+ return Layer.mergeAll(
101
+ OtlpTracer.layerFromConfig({ resource: resourceOf(resource) }),
102
+ OtlpMetrics.layerFromConfig({ resource: resourceOf(resource) })
103
+ ).pipe(
104
+ Layer.provide(OtlpSerialization.layerJson),
105
+ Layer.provide(FetchHttpClient.layer),
106
+ Layer.provide(
107
+ ConfigProvider.layer(
108
+ ConfigProvider.fromEnvRecord({
109
+ ...environment,
110
+ ...otelEnvironmentDefaults(config, environment)
111
+ })
112
+ )
113
+ )
114
+ )
115
+ }
116
+ }
117
+
118
+ /**
119
+ * `layerFromConfig` exports nothing unless `OTEL_TRACES_EXPORTER` names
120
+ * `otlp`; an endpoint in the environment is the operator's intent, so the
121
+ * layer reads its variables with that default (and the metrics one) added
122
+ * when they are unset.
123
+ */
124
+ export const otelEnvironmentDefaults = (
125
+ config: OtelConfig,
126
+ environment: Readonly<Record<string, string | undefined>>
127
+ ): Readonly<Record<string, string>> =>
128
+ config.mode !== "env"
129
+ ? {}
130
+ : {
131
+ ...(environment.OTEL_TRACES_EXPORTER === undefined ? { OTEL_TRACES_EXPORTER: "otlp" } : {}),
132
+ ...(environment.OTEL_METRICS_EXPORTER === undefined
133
+ ? { OTEL_METRICS_EXPORTER: "otlp" }
134
+ : {})
135
+ }
136
+
137
+ export const otelSummary = (config: OtelConfig): string | undefined => {
138
+ switch (config.mode) {
139
+ case "off":
140
+ return undefined
141
+ case "phoenix":
142
+ return `otel → Phoenix at ${config.tracesUrl} (traces)`
143
+ case "env":
144
+ return `otel → ${config.endpoint} (traces + metrics, OTEL_* variables)`
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The one warning a run prints when export is on and nothing answers at the
150
+ * endpoint (spec §9): the exporter itself only logs at debug level and drops
151
+ * the batch. Nothing when off or when the endpoint answered.
152
+ */
153
+ export const otelWarning = (
154
+ config: OtelConfig,
155
+ answers: boolean | undefined
156
+ ): string | undefined => {
157
+ if (config.mode === "off" || answers !== false) {
158
+ return undefined
159
+ }
160
+ const where = config.mode === "phoenix" ? config.tracesUrl : config.endpoint
161
+ return (
162
+ `⚠ otel: nothing answers at ${where} — spans will be dropped (the run is unaffected)` +
163
+ (config.mode === "phoenix"
164
+ ? "; start Phoenix with `docker run -p 6006:6006 arizephoenix/phoenix:latest`"
165
+ : "")
166
+ )
167
+ }
168
+
169
+ /** The doctor's line: `answers` is the probe's result, undefined when export is off. */
170
+ export const otelDoctorLine = (config: OtelConfig, answers: boolean | undefined): string => {
171
+ switch (config.mode) {
172
+ case "off":
173
+ return "otel: off (LLM4TS_OTEL=on for a local Phoenix, or OTEL_EXPORTER_OTLP_ENDPOINT)"
174
+ case "phoenix":
175
+ case "env": {
176
+ const where = config.mode === "phoenix" ? `Phoenix at ${config.tracesUrl}` : config.endpoint
177
+ return `otel: ${where} — ${
178
+ answers === true
179
+ ? "endpoint answers"
180
+ : "endpoint does not answer (spans will be dropped, the run is unaffected)"
181
+ }`
182
+ }
183
+ }
184
+ }
185
+
186
+ /** Whether anything listens at the endpoint: any HTTP answer counts, a refused connection does not. */
187
+ export const probeOtelEndpoint = (config: OtelConfig): Effect.Effect<boolean | undefined> =>
188
+ config.mode === "off"
189
+ ? Effect.succeed(undefined)
190
+ : Effect.tryPromise(() =>
191
+ fetch(config.mode === "phoenix" ? config.tracesUrl : config.endpoint, {
192
+ method: "OPTIONS",
193
+ signal: AbortSignal.timeout(1_500)
194
+ })
195
+ ).pipe(
196
+ Effect.as(true),
197
+ Effect.orElseSucceed(() => false)
198
+ )
package/src/Package.ts CHANGED
@@ -1 +1,11 @@
1
+ import { createRequire } from "node:module"
2
+ import * as Schema from "effect/Schema"
3
+
4
+ const Manifest = Schema.Struct({ version: Schema.String })
5
+
6
+ const manifest = Schema.decodeUnknownSync(Manifest)(
7
+ createRequire(import.meta.url)("../package.json")
8
+ )
9
+
1
10
  export const packageName = "@llm4ts/runner"
11
+ export const packageVersion: string = manifest.version