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