@xandreed/plugin-telemetry 0.4.0 → 0.14.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/README.md +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +7 -2
- package/dist/otlp-telemetry.adapter.d.ts +41 -0
- package/dist/otlp-telemetry.adapter.js +28 -0
- package/dist/otlp-telemetry.adapter.test.d.ts +1 -0
- package/dist/otlp-telemetry.entity.d.ts +74 -0
- package/dist/otlp-telemetry.entity.functions.d.ts +43 -0
- package/dist/otlp-telemetry.entity.functions.js +66 -0
- package/dist/otlp-telemetry.entity.functions.test.d.ts +1 -0
- package/dist/otlp-telemetry.entity.js +2 -0
- package/dist/plugin.adapter.d.ts +56 -2
- package/dist/plugin.adapter.js +11 -13
- package/dist/plugin.adapter.test.d.ts +1 -0
- package/dist/scripted-turn.testing.d.ts +30 -0
- package/dist/telemetry-metrics.d.ts +27 -0
- package/dist/telemetry-metrics.js +31 -0
- package/dist/telemetry.entity.d.ts +50 -0
- package/dist/telemetry.entity.functions.d.ts +22 -0
- package/dist/telemetry.entity.functions.js +20 -0
- package/dist/telemetry.entity.functions.test.d.ts +1 -0
- package/dist/telemetry.entity.js +10 -0
- package/dist/turn-telemetry.adapter.d.ts +20 -0
- package/dist/turn-telemetry.adapter.js +53 -0
- package/dist/turn-telemetry.adapter.test.d.ts +1 -0
- package/package.json +8 -13
- package/dist/telemetry/telemetry.d.ts +0 -21
- package/dist/telemetry/telemetry.js +0 -24
package/README.md
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
1
|
+
export { flushTelemetry, OtlpTelemetryFromEnv, OtlpTelemetryHttpFromEnv, OtlpTelemetryHttpLive, OtlpTelemetryLive } from "./otlp-telemetry.adapter.js";
|
|
2
|
+
export { OtlpCompression, OtlpProtocol, OtlpTemporality } from "./otlp-telemetry.entity.js";
|
|
3
|
+
export type { OtlpTelemetryOptions } from "./otlp-telemetry.entity.js";
|
|
4
|
+
export { telemetryPlugin, TelemetryPluginLive } from "./plugin.adapter.js";
|
|
5
|
+
export { TelemetryMetrics } from "./telemetry-metrics.js";
|
|
6
|
+
export { telemetryDefaults, TelemetryOptions, TokenKind } from "./telemetry.entity.js";
|
|
7
|
+
export { tokenCounts } from "./telemetry.entity.functions.js";
|
|
8
|
+
export { TurnTelemetryLive } from "./turn-telemetry.adapter.js";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
1
|
+
export { flushTelemetry, OtlpTelemetryFromEnv, OtlpTelemetryHttpFromEnv, OtlpTelemetryHttpLive, OtlpTelemetryLive } from "./otlp-telemetry.adapter.js";
|
|
2
|
+
export { OtlpCompression, OtlpProtocol, OtlpTemporality } from "./otlp-telemetry.entity.js";
|
|
3
|
+
export { telemetryPlugin, TelemetryPluginLive } from "./plugin.adapter.js";
|
|
4
|
+
export { TelemetryMetrics } from "./telemetry-metrics.js";
|
|
5
|
+
export { telemetryDefaults, TelemetryOptions, TokenKind } from "./telemetry.entity.js";
|
|
6
|
+
export { tokenCounts } from "./telemetry.entity.functions.js";
|
|
7
|
+
export { TurnTelemetryLive } from "./turn-telemetry.adapter.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import type { Config } from "effect";
|
|
3
|
+
import type { HttpClient } from "effect/http";
|
|
4
|
+
import { OtlpExporter } from "effect/observability";
|
|
5
|
+
import type { OtlpTelemetryOptions } from "./otlp-telemetry.entity.js";
|
|
6
|
+
/**
|
|
7
|
+
* Export the program's spans and metrics over OTLP/HTTP with the host's
|
|
8
|
+
* `HttpClient` (one that goes through a proxy, say): Effect's own
|
|
9
|
+
* `OtlpTracer` and `OtlpMetrics` (`effect/observability`), configured by
|
|
10
|
+
* `options` (`OtlpTelemetryOptions`). Provide it around the program that
|
|
11
|
+
* defines the agent and runs its turns; it is the `Tracer` and the metrics
|
|
12
|
+
* exporter of everything inside. Spans are batched and posted every
|
|
13
|
+
* `exportInterval`; the metrics registry (every `Metric` the program
|
|
14
|
+
* updated: the telemetry plugin's, the step loop's, the models plugin's,
|
|
15
|
+
* `Agent.turn`'s) is posted whole each interval. Closing the layer posts
|
|
16
|
+
* what is left, waiting up to `shutdownTimeout`; a serverless host flushes
|
|
17
|
+
* before it responds (`flushTelemetry`). A failed export is retried and
|
|
18
|
+
* then turns that signal off for 60 seconds; it never fails the program.
|
|
19
|
+
*/
|
|
20
|
+
export declare const OtlpTelemetryHttpLive: (options: OtlpTelemetryOptions) => Layer.Layer<OtlpExporter.Flusher, never, HttpClient.HttpClient>;
|
|
21
|
+
/** `OtlpTelemetryHttpLive` over the global `fetch` (`FetchHttpClient`), on Node 24 and Bun. */
|
|
22
|
+
export declare const OtlpTelemetryLive: (options: OtlpTelemetryOptions) => Layer.Layer<OtlpExporter.Flusher>;
|
|
23
|
+
/**
|
|
24
|
+
* OTLP export configured by the standard OpenTelemetry environment
|
|
25
|
+
* (`OTEL_EXPORTER_OTLP_ENDPOINT`, the per-signal endpoints, headers,
|
|
26
|
+
* compression and protocol, `OTEL_SERVICE_NAME`, `OTEL_SDK_DISABLED`, …;
|
|
27
|
+
* see `otlpExportFromEnv`), over `defaults` for what it leaves unsaid,
|
|
28
|
+
* with the host's `HttpClient`. With no endpoint, or with
|
|
29
|
+
* `OTEL_SDK_DISABLED=true`, it exports nothing and reads nothing further,
|
|
30
|
+
* so a host can always provide it. A variable it reads that is set to an
|
|
31
|
+
* invalid value fails the layer with its `ConfigError`.
|
|
32
|
+
*/
|
|
33
|
+
export declare const OtlpTelemetryHttpFromEnv: (defaults?: OtlpTelemetryOptions) => Layer.Layer<OtlpExporter.Flusher, Config.ConfigError, HttpClient.HttpClient>;
|
|
34
|
+
/** `OtlpTelemetryHttpFromEnv` over the global `fetch` (`FetchHttpClient`), on Node 24 and Bun. */
|
|
35
|
+
export declare const OtlpTelemetryFromEnv: (defaults?: OtlpTelemetryOptions) => Layer.Layer<OtlpExporter.Flusher, Config.ConfigError>;
|
|
36
|
+
/**
|
|
37
|
+
* Post every signal's pending batch now and wait for those posts (not for
|
|
38
|
+
* one already in flight). It cannot fail; bound it with
|
|
39
|
+
* `Effect.timeoutOption` where the host has a deadline.
|
|
40
|
+
*/
|
|
41
|
+
export declare const flushTelemetry: Effect.Effect<void, never, OtlpExporter.Flusher>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Effect, Layer, Option } from "effect";
|
|
2
|
+
import { FetchHttpClient } from "effect/http";
|
|
3
|
+
import { OtlpExporter, OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/observability";
|
|
4
|
+
import { otlpExportFromEnv, otlpExportOf } from "./otlp-telemetry.entity.functions.js";
|
|
5
|
+
const serialization = (protocol) => protocol === "http/json" ? OtlpSerialization.layerJson : OtlpSerialization.layerProtobuf, exportLayer = (target) => {
|
|
6
|
+
const resource = {
|
|
7
|
+
serviceName: target.serviceName,
|
|
8
|
+
serviceVersion: Option.getOrUndefined(target.serviceVersion),
|
|
9
|
+
attributes: target.resourceAttributes
|
|
10
|
+
}, shutdownTimeout = Option.getOrUndefined(target.shutdownTimeout), traces = Option.map(target.traces, (signal) => OtlpTracer.layer({
|
|
11
|
+
url: signal.url,
|
|
12
|
+
resource,
|
|
13
|
+
headers: signal.headers,
|
|
14
|
+
compression: signal.compression,
|
|
15
|
+
exportInterval: Option.getOrUndefined(signal.exportInterval),
|
|
16
|
+
shutdownTimeout
|
|
17
|
+
}).pipe(Layer.provide(serialization(signal.protocol)))), metrics = Option.map(target.metrics, (signal) => OtlpMetrics.layer({
|
|
18
|
+
url: signal.url,
|
|
19
|
+
resource,
|
|
20
|
+
headers: signal.headers,
|
|
21
|
+
compression: signal.compression,
|
|
22
|
+
exportInterval: Option.getOrUndefined(signal.exportInterval),
|
|
23
|
+
shutdownTimeout,
|
|
24
|
+
temporality: target.metricsTemporality
|
|
25
|
+
}).pipe(Layer.provide(serialization(signal.protocol))));
|
|
26
|
+
return [traces, metrics].flatMap(Option.toArray).reduce((merged, signal) => Layer.merge(merged, signal), OtlpExporter.layerFlusher);
|
|
27
|
+
};
|
|
28
|
+
export const OtlpTelemetryHttpLive = (options) => exportLayer(otlpExportOf(options)), OtlpTelemetryLive = (options) => OtlpTelemetryHttpLive(options).pipe(Layer.provide(FetchHttpClient.layer)), OtlpTelemetryHttpFromEnv = (defaults = {}) => Layer.unwrap(otlpExportFromEnv(defaults).pipe(Effect.map(exportLayer))), OtlpTelemetryFromEnv = (defaults = {}) => OtlpTelemetryHttpFromEnv(defaults).pipe(Layer.provide(FetchHttpClient.layer)), flushTelemetry = OtlpExporter.Flusher.pipe(Effect.flatMap((flusher) => flusher.flush));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import type { Duration, Option } from "effect";
|
|
3
|
+
/** How a signal's batches are encoded: OTLP/HTTP protobuf (the OpenTelemetry default) or JSON. gRPC is not supported. */
|
|
4
|
+
export declare const OtlpProtocol: Schema.Literals<readonly ["http/protobuf", "http/json"]>;
|
|
5
|
+
export type OtlpProtocol = typeof OtlpProtocol.Type;
|
|
6
|
+
/** Request compression; gzip leaves a batch under 1 KB uncompressed. */
|
|
7
|
+
export declare const OtlpCompression: Schema.Literals<readonly ["gzip", "none"]>;
|
|
8
|
+
export type OtlpCompression = typeof OtlpCompression.Type;
|
|
9
|
+
/** How counters and histograms are reported: totals since the start (`cumulative`) or since the last export (`delta`). */
|
|
10
|
+
export declare const OtlpTemporality: Schema.Literals<readonly ["cumulative", "delta"]>;
|
|
11
|
+
export type OtlpTemporality = typeof OtlpTemporality.Type;
|
|
12
|
+
/** The signals the layers export; logs are left to the host. */
|
|
13
|
+
export type OtlpSignal = "traces" | "metrics";
|
|
14
|
+
/**
|
|
15
|
+
* Where and how `OtlpTelemetryLive` exports traces and metrics over
|
|
16
|
+
* OTLP/HTTP. A signal goes to its own URL (`tracesUrl`, `metricsUrl`, used
|
|
17
|
+
* as given) or else to `baseUrl` plus `/v1/traces` or `/v1/metrics`; a
|
|
18
|
+
* signal with neither, or turned off (`traces: false`, `metrics: false`),
|
|
19
|
+
* is not exported.
|
|
20
|
+
* - `serviceName`: the resource's `service.name`; else
|
|
21
|
+
* `resourceAttributes["service.name"]`, else `"efferent"`. It always
|
|
22
|
+
* wins over `OTEL_SERVICE_NAME` and the `service.name` of
|
|
23
|
+
* `OTEL_RESOURCE_ATTRIBUTES` (`OtlpTelemetryFromEnv` reads those).
|
|
24
|
+
* `serviceVersion` (else `resourceAttributes["service.version"]`) and
|
|
25
|
+
* `resourceAttributes` join the resource, over any other
|
|
26
|
+
* `OTEL_RESOURCE_ATTRIBUTES` Effect's exporter still merges in; with no
|
|
27
|
+
* `serviceVersion` at all, Effect's exporter reads `OTEL_SERVICE_VERSION`.
|
|
28
|
+
* - `headers`: sent with every request (an API key, a tenant), both
|
|
29
|
+
* signals; `tracesHeaders` and `metricsHeaders` go to one signal only,
|
|
30
|
+
* over `headers` (a signal at another vendor, with its own key).
|
|
31
|
+
* - `compression`: `"none"` by default; `protocol`: `"http/protobuf"` by default.
|
|
32
|
+
* - `exportInterval`: how often batches are sent (Effect's defaults: 5 s for
|
|
33
|
+
* traces, 10 s for metrics); `shutdownTimeout`: how long closing the layer
|
|
34
|
+
* waits for the last export (3 s).
|
|
35
|
+
* - `metricsTemporality`: `"cumulative"` by default.
|
|
36
|
+
*/
|
|
37
|
+
export interface OtlpTelemetryOptions {
|
|
38
|
+
readonly baseUrl?: string;
|
|
39
|
+
readonly tracesUrl?: string;
|
|
40
|
+
readonly metricsUrl?: string;
|
|
41
|
+
readonly traces?: boolean;
|
|
42
|
+
readonly metrics?: boolean;
|
|
43
|
+
readonly serviceName?: string;
|
|
44
|
+
readonly serviceVersion?: string;
|
|
45
|
+
readonly resourceAttributes?: Readonly<Record<string, string>>;
|
|
46
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
47
|
+
readonly tracesHeaders?: Readonly<Record<string, string>>;
|
|
48
|
+
readonly metricsHeaders?: Readonly<Record<string, string>>;
|
|
49
|
+
readonly compression?: OtlpCompression;
|
|
50
|
+
readonly protocol?: OtlpProtocol;
|
|
51
|
+
readonly exportInterval?: Duration.Input;
|
|
52
|
+
readonly shutdownTimeout?: Duration.Input;
|
|
53
|
+
readonly metricsTemporality?: OtlpTemporality;
|
|
54
|
+
}
|
|
55
|
+
/** One signal's export, resolved: the URL it is posted to and how. */
|
|
56
|
+
export interface OtlpSignalExport {
|
|
57
|
+
readonly url: string;
|
|
58
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
59
|
+
readonly compression: OtlpCompression;
|
|
60
|
+
readonly protocol: OtlpProtocol;
|
|
61
|
+
readonly exportInterval: Option.Option<Duration.Input>;
|
|
62
|
+
}
|
|
63
|
+
/** The export the layers build, resolved from options or the environment: a signal that is `None` is not exported. */
|
|
64
|
+
export interface OtlpExport {
|
|
65
|
+
readonly serviceName: string;
|
|
66
|
+
readonly serviceVersion: Option.Option<string>;
|
|
67
|
+
readonly resourceAttributes: Readonly<Record<string, string>>;
|
|
68
|
+
readonly traces: Option.Option<OtlpSignalExport>;
|
|
69
|
+
readonly metrics: Option.Option<OtlpSignalExport>;
|
|
70
|
+
readonly metricsTemporality: OtlpTemporality;
|
|
71
|
+
readonly shutdownTimeout: Option.Option<Duration.Input>;
|
|
72
|
+
}
|
|
73
|
+
/** The service name a resource gets when nothing names one. */
|
|
74
|
+
export declare const DEFAULT_SERVICE_NAME = "efferent";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Config, Effect } from "effect";
|
|
2
|
+
import type { OtlpExport, OtlpSignal, OtlpTelemetryOptions } from "./otlp-telemetry.entity.js";
|
|
3
|
+
/**
|
|
4
|
+
* A signal's URL under a collector's base URL: its path plus `/v1/traces`
|
|
5
|
+
* or `/v1/metrics` (one slash between), the query string kept.
|
|
6
|
+
*/
|
|
7
|
+
export declare const signalUrl: (base: string, signal: OtlpSignal) => string;
|
|
8
|
+
/** The export `OtlpTelemetryLive` builds from its options (see `OtlpTelemetryOptions` for the defaults). */
|
|
9
|
+
export declare const otlpExportOf: (options: OtlpTelemetryOptions) => OtlpExport;
|
|
10
|
+
/**
|
|
11
|
+
* The export the standard OpenTelemetry environment describes, over
|
|
12
|
+
* `defaults` for what it leaves unsaid (read from the current
|
|
13
|
+
* `ConfigProvider`, the process environment by default):
|
|
14
|
+
* - `OTEL_SDK_DISABLED=true` exports nothing, and `OTEL_TRACES_EXPORTER` /
|
|
15
|
+
* `OTEL_METRICS_EXPORTER` export that signal only when they list `otlp`
|
|
16
|
+
* (`none` turns it off; unset leaves `defaults.traces`/`metrics`, on);
|
|
17
|
+
* - a signal's endpoint is `OTEL_EXPORTER_OTLP_{TRACES,METRICS}_ENDPOINT`
|
|
18
|
+
* as given, else `OTEL_EXPORTER_OTLP_ENDPOINT` plus `/v1/<signal>`, else
|
|
19
|
+
* the defaults' URLs; a signal with no endpoint is not exported, so an
|
|
20
|
+
* empty environment exports nothing;
|
|
21
|
+
* - headers are `OTEL_EXPORTER_OTLP_HEADERS` with the signal's own
|
|
22
|
+
* `..._{TRACES,METRICS}_HEADERS` over them, over the defaults' headers;
|
|
23
|
+
* compression and protocol are the signal's own variable, else the
|
|
24
|
+
* shared one, else the defaults (`none`, `http/protobuf`; `grpc` fails);
|
|
25
|
+
* - `service.name` is `OTEL_SERVICE_NAME`, else `OTEL_RESOURCE_ATTRIBUTES`'
|
|
26
|
+
* `service.name`, else `defaults.serviceName`, else the defaults'
|
|
27
|
+
* `resourceAttributes["service.name"]`, else `"efferent"`;
|
|
28
|
+
* `service.version` likewise from `OTEL_SERVICE_VERSION`;
|
|
29
|
+
* `OTEL_RESOURCE_ATTRIBUTES` win over `defaults.resourceAttributes`;
|
|
30
|
+
* - the export intervals are `OTEL_BSP_SCHEDULE_DELAY` (traces) and
|
|
31
|
+
* `OTEL_METRIC_EXPORT_INTERVAL` (metrics), in milliseconds, and the
|
|
32
|
+
* temporality `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`;
|
|
33
|
+
* `OTEL_EXPORTER_OTLP_TIMEOUT` is not read (`shutdownTimeout` is the
|
|
34
|
+
* defaults').
|
|
35
|
+
* A variable is read only when it can matter: nothing past
|
|
36
|
+
* `OTEL_SDK_DISABLED` when it is set, nothing about a signal past its
|
|
37
|
+
* exporter list when that turns it off, and nothing past the endpoints
|
|
38
|
+
* when no signal has one; a signal's own endpoint, compression and
|
|
39
|
+
* protocol are read before the shared ones, which are not read when the
|
|
40
|
+
* signal's are set (headers merge, so both are read). A variable that
|
|
41
|
+
* is read and set to a value it cannot use fails with its `ConfigError`.
|
|
42
|
+
*/
|
|
43
|
+
export declare const otlpExportFromEnv: (defaults?: OtlpTelemetryOptions) => Effect.Effect<OtlpExport, Config.ConfigError>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Config, Duration, Effect, Option, Schema } from "effect";
|
|
2
|
+
import { DEFAULT_SERVICE_NAME, OtlpCompression, OtlpProtocol } from "./otlp-telemetry.entity.js";
|
|
3
|
+
export const signalUrl = (base, signal) => Option.match(Option.fromNullishOr(URL.parse(base)), {
|
|
4
|
+
onNone: () => `${base.replace(/\/+$/, "")}/v1/${signal}`,
|
|
5
|
+
onSome: (url) => {
|
|
6
|
+
const target = new URL(url);
|
|
7
|
+
target.pathname = `${target.pathname.replace(/\/+$/, "")}/v1/${signal}`;
|
|
8
|
+
return target.toString();
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
const signalOptions = (options, signal) => signal === "traces" ? { enabled: options.traces, url: options.tracesUrl, headers: options.tracesHeaders } : { enabled: options.metrics, url: options.metricsUrl, headers: options.metricsHeaders }, optionsUrl = (options, signal) => Option.firstSomeOf([
|
|
12
|
+
Option.fromNullishOr(signalOptions(options, signal).url),
|
|
13
|
+
Option.map(Option.fromNullishOr(options.baseUrl), (base) => signalUrl(base, signal))
|
|
14
|
+
]), optionsHeaders = (options, signal) => ({ ...options.headers, ...signalOptions(options, signal).headers }), optionsResource = (options) => {
|
|
15
|
+
const attributes = options.resourceAttributes ?? {};
|
|
16
|
+
return {
|
|
17
|
+
serviceName: options.serviceName ?? attributes["service.name"] ?? DEFAULT_SERVICE_NAME,
|
|
18
|
+
serviceVersion: Option.fromNullishOr(options.serviceVersion ?? attributes["service.version"]),
|
|
19
|
+
resourceAttributes: attributes
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export const otlpExportOf = (options) => {
|
|
23
|
+
const signal = (name) => signalOptions(options, name).enabled === !1 ? Option.none() : Option.map(optionsUrl(options, name), (url) => ({
|
|
24
|
+
url,
|
|
25
|
+
headers: optionsHeaders(options, name),
|
|
26
|
+
compression: options.compression ?? "none",
|
|
27
|
+
protocol: options.protocol ?? "http/protobuf",
|
|
28
|
+
exportInterval: Option.fromNullishOr(options.exportInterval)
|
|
29
|
+
}));
|
|
30
|
+
return {
|
|
31
|
+
...optionsResource(options),
|
|
32
|
+
traces: signal("traces"),
|
|
33
|
+
metrics: signal("metrics"),
|
|
34
|
+
metricsTemporality: options.metricsTemporality ?? "cumulative",
|
|
35
|
+
shutdownTimeout: Option.fromNullishOr(options.shutdownTimeout)
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
const keyValues = (name, decodeKeys = !1) => Config.option(Config.Record(decodeKeys ? Schema.StringFromUriComponent : Schema.String, Schema.StringFromUriComponent, name)).pipe(Config.map(Option.map((record) => Object.fromEntries(Object.entries(record).map(([key, value]) => [key.trim(), value.trim()]).filter(([key]) => key.length > 0))))), firstSet = (configs) => configs.reduce((found, next) => Effect.flatMap(found, (value) => Option.isSome(value) ? Effect.succeed(value) : next), Effect.succeed(Option.none())), temporalityPreference = Config.option(Config.Literals(["cumulative", "delta", "lowmemory"], "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE")).pipe(Config.map(Option.map((value) => value === "cumulative" ? "cumulative" : "delta"))), intervalVariable = { traces: "OTEL_BSP_SCHEDULE_DELAY", metrics: "OTEL_METRIC_EXPORT_INTERVAL" }, signalFromEnv = (name, defaults) => Effect.gen(function* () {
|
|
39
|
+
const upper = name.toUpperCase(), own = `OTEL_EXPORTER_OTLP_${upper}_`, exporters = (yield* Config.Array(Schema.String, `OTEL_${upper}_EXPORTER`).pipe(Config.withDefault([]))).map((exporter) => exporter.trim().toLowerCase()).filter((exporter) => exporter.length > 0), url = (exporters.length > 0 ? exporters.includes("otlp") : signalOptions(defaults, name).enabled !== !1) ? Option.orElse(yield* firstSet([
|
|
40
|
+
Config.option(Config.URL(`${own}ENDPOINT`)).pipe(Config.map(Option.map((endpoint) => endpoint.toString()))),
|
|
41
|
+
Config.option(Config.URL("OTEL_EXPORTER_OTLP_ENDPOINT")).pipe(Config.map(Option.map((endpoint) => signalUrl(endpoint.toString(), name))))
|
|
42
|
+
]), () => optionsUrl(defaults, name)) : Option.none();
|
|
43
|
+
return Option.isNone(url) ? Option.none() : Option.some({
|
|
44
|
+
url: url.value,
|
|
45
|
+
headers: {
|
|
46
|
+
...optionsHeaders(defaults, name),
|
|
47
|
+
...Option.getOrElse(yield* keyValues("OTEL_EXPORTER_OTLP_HEADERS"), () => ({})),
|
|
48
|
+
...Option.getOrElse(yield* keyValues(`${own}HEADERS`), () => ({}))
|
|
49
|
+
},
|
|
50
|
+
compression: Option.getOrElse(yield* firstSet([Config.option(Config.schema(OtlpCompression, `${own}COMPRESSION`)), Config.option(Config.schema(OtlpCompression, "OTEL_EXPORTER_OTLP_COMPRESSION"))]), () => defaults.compression ?? "none"),
|
|
51
|
+
protocol: Option.getOrElse(yield* firstSet([Config.option(Config.schema(OtlpProtocol, `${own}PROTOCOL`)), Config.option(Config.schema(OtlpProtocol, "OTEL_EXPORTER_OTLP_PROTOCOL"))]), () => defaults.protocol ?? "http/protobuf"),
|
|
52
|
+
exportInterval: Option.orElse(Option.map(yield* Config.option(Config.Int(intervalVariable[name])), Duration.millis), () => Option.fromNullishOr(defaults.exportInterval))
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
export const otlpExportFromEnv = (defaults = {}) => Effect.gen(function* () {
|
|
56
|
+
const disabled = yield* Config.Boolean("OTEL_SDK_DISABLED").pipe(Config.withDefault(!1)), traces = disabled ? Option.none() : yield* signalFromEnv("traces", defaults), metrics = disabled ? Option.none() : yield* signalFromEnv("metrics", defaults), base = optionsResource(defaults), exporting = Option.isSome(traces) || Option.isSome(metrics), resource = exporting ? Option.getOrElse(yield* keyValues("OTEL_RESOURCE_ATTRIBUTES", !0), () => ({})) : {}, serviceName = exporting ? yield* Config.option(Config.String("OTEL_SERVICE_NAME")) : Option.none(), serviceVersion = exporting ? yield* Config.option(Config.String("OTEL_SERVICE_VERSION")) : Option.none(), temporality = Option.isSome(metrics) ? yield* temporalityPreference : Option.none();
|
|
57
|
+
return {
|
|
58
|
+
serviceName: Option.getOrElse(Option.firstSomeOf([serviceName, Option.fromNullishOr(resource["service.name"])]), () => base.serviceName),
|
|
59
|
+
serviceVersion: Option.firstSomeOf([serviceVersion, Option.fromNullishOr(resource["service.version"]), base.serviceVersion]),
|
|
60
|
+
resourceAttributes: { ...base.resourceAttributes, ...resource },
|
|
61
|
+
traces,
|
|
62
|
+
metrics,
|
|
63
|
+
metricsTemporality: Option.getOrElse(temporality, () => defaults.metricsTemporality ?? "cumulative"),
|
|
64
|
+
shutdownTimeout: Option.fromNullishOr(defaults.shutdownTimeout)
|
|
65
|
+
};
|
|
66
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/plugin.adapter.d.ts
CHANGED
|
@@ -1,2 +1,56 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { RunContext, TurnEvents } from "@xandreed/core";
|
|
2
|
+
/**
|
|
3
|
+
* Usage, cost and tool telemetry for every turn (`TurnTelemetryLive`):
|
|
4
|
+
* session-scoped, built per turn over the turn's `RunContext` and
|
|
5
|
+
* `TurnEvents`, so under `Agent.turn` it is built inside the turn's
|
|
6
|
+
* `agent.turn` span and closed before the turn's reply is recorded. It
|
|
7
|
+
* provides nothing and exports nothing: the host provides an exporter
|
|
8
|
+
* around its program (`OtlpTelemetryLive`). It keeps the id of the plugin
|
|
9
|
+
* this package shipped before 0.11.0, whose options (`enabled`,
|
|
10
|
+
* `serviceName`) it refuses.
|
|
11
|
+
*/
|
|
12
|
+
export declare const telemetryPlugin: import("@xandreed/core").TypedPlugin<{
|
|
13
|
+
readonly prices: {
|
|
14
|
+
readonly [x: string]: {
|
|
15
|
+
readonly input: number;
|
|
16
|
+
readonly cacheRead: number;
|
|
17
|
+
readonly cacheWrite: number;
|
|
18
|
+
readonly output: number;
|
|
19
|
+
readonly reasoning?: number;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
readonly metricAttributes: {
|
|
23
|
+
readonly [x: string]: string;
|
|
24
|
+
};
|
|
25
|
+
readonly spanEvents: boolean;
|
|
26
|
+
}, {
|
|
27
|
+
readonly prices: {
|
|
28
|
+
readonly [x: string]: {
|
|
29
|
+
readonly input: number;
|
|
30
|
+
readonly cacheRead: number;
|
|
31
|
+
readonly cacheWrite: number;
|
|
32
|
+
readonly output: number;
|
|
33
|
+
readonly reasoning?: number;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
readonly metricAttributes: {
|
|
37
|
+
readonly [x: string]: string;
|
|
38
|
+
};
|
|
39
|
+
readonly spanEvents: boolean;
|
|
40
|
+
}, never, never, TurnEvents | RunContext>;
|
|
41
|
+
/** The telemetry plugin as a typed layer: requires the turn's RunContext and TurnEvents; provides nothing. */
|
|
42
|
+
export declare const TelemetryPluginLive: (options?: Partial<{
|
|
43
|
+
readonly prices: {
|
|
44
|
+
readonly [x: string]: {
|
|
45
|
+
readonly input: number;
|
|
46
|
+
readonly cacheRead: number;
|
|
47
|
+
readonly cacheWrite: number;
|
|
48
|
+
readonly output: number;
|
|
49
|
+
readonly reasoning?: number;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
readonly metricAttributes: {
|
|
53
|
+
readonly [x: string]: string;
|
|
54
|
+
};
|
|
55
|
+
readonly spanEvents: boolean;
|
|
56
|
+
}> | undefined) => import("effect/Layer").Layer<never, import("@xandreed/core").HarnessError, TurnEvents | RunContext>;
|
package/dist/plugin.adapter.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { definePlugin, RunContext, TurnEvents } from "@xandreed/core";
|
|
2
|
+
import { TelemetryOptions, telemetryDefaults } from "./telemetry.entity.js";
|
|
3
|
+
import { TurnTelemetryLive } from "./turn-telemetry.adapter.js";
|
|
4
4
|
export const telemetryPlugin = definePlugin({
|
|
5
5
|
id: "@xandreed/plugin-telemetry",
|
|
6
|
-
version: "0.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
});
|
|
15
|
-
export default telemetryPlugin;
|
|
6
|
+
version: "0.14.0",
|
|
7
|
+
scope: "session",
|
|
8
|
+
config: TelemetryOptions,
|
|
9
|
+
defaults: telemetryDefaults,
|
|
10
|
+
requires: [RunContext, TurnEvents],
|
|
11
|
+
provides: [],
|
|
12
|
+
layer: (config) => TurnTelemetryLive(config)
|
|
13
|
+
}), TelemetryPluginLive = telemetryPlugin.live;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Effect, Metric, Tracer } from "effect";
|
|
2
|
+
import type { HarnessError, ModelPrice, TurnResult } from "@xandreed/core";
|
|
3
|
+
import type { TelemetryOptions } from "./telemetry.entity.js";
|
|
4
|
+
/**
|
|
5
|
+
* A key-free turn for the telemetry tests: a host whose `lookup` runs and
|
|
6
|
+
* whose `archive` may run once per turn, and a scripted model that looks
|
|
7
|
+
* alpha up, archives it twice in one response (the second call is refused,
|
|
8
|
+
* `tool-call-limit`) and answers. Every response bills the same usage,
|
|
9
|
+
* stamped with `MODEL` as the models plugin stamps its selection.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MODEL = "scripted:telemetry-1";
|
|
12
|
+
export declare const PRICE: ModelPrice;
|
|
13
|
+
export declare const conversation: string & import("effect/Brand").Brand<"ConversationId">;
|
|
14
|
+
export declare const RESPONSES: number;
|
|
15
|
+
/** A tracer keeping every span it starts. */
|
|
16
|
+
export declare const capturingTracer: (spans: Array<Tracer.NativeSpan>) => Tracer.Tracer;
|
|
17
|
+
/**
|
|
18
|
+
* Define an agent with the telemetry plugin (`options`) and run the
|
|
19
|
+
* scripted turn, its metrics in `registry` (else the ambient one). With `streaming`, every model
|
|
20
|
+
* call is first streamed and falls back. The turn runs under the ambient
|
|
21
|
+
* tracer (an exporter's), or `tracer` when given.
|
|
22
|
+
*/
|
|
23
|
+
export declare const scriptedTurn: (options: {
|
|
24
|
+
readonly registry?: Metric.MetricRegistry;
|
|
25
|
+
readonly telemetry?: Partial<TelemetryOptions> & Readonly<Record<string, unknown>>;
|
|
26
|
+
readonly streaming?: boolean;
|
|
27
|
+
readonly tracer?: Tracer.Tracer;
|
|
28
|
+
}) => Effect.Effect<TurnResult, HarnessError>;
|
|
29
|
+
/** A metric's value in `registry`. */
|
|
30
|
+
export declare const valueIn: <Input, State>(registry: Metric.MetricRegistry, metric: Metric.Metric<Input, State>, attributes: Readonly<Record<string, string>>) => Effect.Effect<State>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Metric } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* The metrics the telemetry plugin records, as Effect `Metric`s (a metric's
|
|
4
|
+
* name, description and attributes are its identity in the registry, so a
|
|
5
|
+
* dashboard or test reads them through these definitions). Every one also
|
|
6
|
+
* carries the plugin's `metricAttributes`; none carries a session, run or
|
|
7
|
+
* turn id.
|
|
8
|
+
*
|
|
9
|
+
* | Metric | Attributes | Counts |
|
|
10
|
+
* |---|---|---|
|
|
11
|
+
* | `efferent.llm.tokens` | `efferent.model`, `efferent.token.kind` | tokens of each response, by billed kind (`input`, `cache_read`, `cache_write`, `reasoning`, `output`; they sum to the response's total) |
|
|
12
|
+
* | `efferent.llm.cost` | `efferent.model` | what each priced response cost, in the prices' currency |
|
|
13
|
+
* | `efferent.steps` | `efferent.step.status` | steps of the step loop, by how they ended (`completed`, `failed`, `cancelled`) |
|
|
14
|
+
* | `efferent.tool.calls` | `efferent.tool`, `efferent.ok` | tool calls whose handler ran, by result |
|
|
15
|
+
* | `efferent.tool.duration` | `efferent.tool`, `efferent.ok` | those calls' time in milliseconds, from `tool.started` to the handler's exit |
|
|
16
|
+
* | `efferent.tool.refused` | `efferent.tool`, `efferent.refusal.reason` | calls the tool registry refused before the handler ran |
|
|
17
|
+
* | `efferent.stream.fallbacks` | — | streamed model calls that failed before their first part and were answered by a non-streamed call |
|
|
18
|
+
*/
|
|
19
|
+
export declare const TelemetryMetrics: {
|
|
20
|
+
readonly tokens: Metric.Counter<number>;
|
|
21
|
+
readonly cost: Metric.Counter<number>;
|
|
22
|
+
readonly steps: Metric.Counter<number>;
|
|
23
|
+
readonly toolCalls: Metric.Counter<number>;
|
|
24
|
+
readonly toolDuration: Metric.Histogram<import("effect/Duration").Duration>;
|
|
25
|
+
readonly toolRefused: Metric.Counter<number>;
|
|
26
|
+
readonly streamFallbacks: Metric.Counter<number>;
|
|
27
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Metric } from "effect";
|
|
2
|
+
export const TelemetryMetrics = {
|
|
3
|
+
tokens: Metric.counter("efferent.llm.tokens", {
|
|
4
|
+
description: "tokens of each model response, by billed kind",
|
|
5
|
+
incremental: !0
|
|
6
|
+
}),
|
|
7
|
+
cost: Metric.counter("efferent.llm.cost", {
|
|
8
|
+
description: "cost of each priced model response, in the prices' currency",
|
|
9
|
+
incremental: !0
|
|
10
|
+
}),
|
|
11
|
+
steps: Metric.counter("efferent.steps", {
|
|
12
|
+
description: "steps of the step loop, by how they ended",
|
|
13
|
+
incremental: !0
|
|
14
|
+
}),
|
|
15
|
+
toolCalls: Metric.counter("efferent.tool.calls", {
|
|
16
|
+
description: "tool calls whose handler ran, by result",
|
|
17
|
+
incremental: !0
|
|
18
|
+
}),
|
|
19
|
+
toolDuration: Metric.timer("efferent.tool.duration", {
|
|
20
|
+
description: "time of each tool call whose handler ran, in milliseconds",
|
|
21
|
+
boundaries: [10, 50, 100, 250, 500, 1000, 2500, 5000, 1e4, 30000, 60000, 120000, 300000]
|
|
22
|
+
}),
|
|
23
|
+
toolRefused: Metric.counter("efferent.tool.refused", {
|
|
24
|
+
description: "tool calls the registry refused before the handler ran, by reason",
|
|
25
|
+
incremental: !0
|
|
26
|
+
}),
|
|
27
|
+
streamFallbacks: Metric.counter("efferent.stream.fallbacks", {
|
|
28
|
+
description: "streamed model calls that fell back to a non-streamed call",
|
|
29
|
+
incremental: !0
|
|
30
|
+
})
|
|
31
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* What the telemetry plugin records for each turn it sees.
|
|
4
|
+
* - `prices`: a model's prices (per million tokens, in the host's currency,
|
|
5
|
+
* usually USD), keyed by the model as each response names it: the
|
|
6
|
+
* models plugin's `"<provider>:<modelId>"` selection (`assistant.message`
|
|
7
|
+
* `model`). A response from an unpriced model, or with no model (a
|
|
8
|
+
* `LanguageModel` the models plugin did not route), has no cost.
|
|
9
|
+
* - `metricAttributes`: constant attributes added to every metric the
|
|
10
|
+
* plugin records, such as `deployment.environment`. Keep them few and
|
|
11
|
+
* bounded: each distinct value is a new series. Never put a session, run
|
|
12
|
+
* or user id here (those are on the spans).
|
|
13
|
+
* - `spanEvents`: whether the plugin also writes to the span current where
|
|
14
|
+
* it is built (the turn's `agent.turn` under `Agent.turn`): the turn's ids
|
|
15
|
+
* as attributes, an event per response, refusal and stream fallback, and
|
|
16
|
+
* the running cost.
|
|
17
|
+
*
|
|
18
|
+
* Flat: the plugin's defaults merge one level deep. The options of the
|
|
19
|
+
* plugin this package shipped before 0.11.0 (`enabled`, `serviceName`) are
|
|
20
|
+
* refused; export is the host's `OtlpTelemetryLive` now.
|
|
21
|
+
*/
|
|
22
|
+
export declare const TelemetryOptions: Schema.Struct<{
|
|
23
|
+
readonly prices: Schema.$Record<Schema.String, Schema.Struct<{
|
|
24
|
+
readonly input: Schema.Finite;
|
|
25
|
+
readonly output: Schema.Finite;
|
|
26
|
+
readonly cacheRead: Schema.Finite;
|
|
27
|
+
readonly cacheWrite: Schema.Finite;
|
|
28
|
+
readonly reasoning: Schema.optionalKey<Schema.Finite>;
|
|
29
|
+
}>>;
|
|
30
|
+
readonly metricAttributes: Schema.$Record<Schema.String, Schema.String>;
|
|
31
|
+
readonly spanEvents: Schema.Boolean;
|
|
32
|
+
}>;
|
|
33
|
+
export type TelemetryOptions = typeof TelemetryOptions.Type;
|
|
34
|
+
export declare const telemetryDefaults: TelemetryOptions;
|
|
35
|
+
/**
|
|
36
|
+
* The five kinds a response's tokens are billed by, as `efferent.token.kind`
|
|
37
|
+
* names them (core's `usageBreakdown`): uncached input, cache reads, cache
|
|
38
|
+
* writes, reasoning, and output without its reasoning. For one response
|
|
39
|
+
* they sum to its `totalTokens`.
|
|
40
|
+
*/
|
|
41
|
+
export declare const TokenKind: Schema.Literals<readonly ["input", "cache_read", "cache_write", "reasoning", "output"]>;
|
|
42
|
+
export type TokenKind = typeof TokenKind.Type;
|
|
43
|
+
/** A response's tokens of one kind (`count > 0` only: a kind the response did not bill is left out). */
|
|
44
|
+
export declare const TokenCount: Schema.Struct<{
|
|
45
|
+
readonly kind: Schema.Literals<readonly ["input", "cache_read", "cache_write", "reasoning", "output"]>;
|
|
46
|
+
readonly count: Schema.Number;
|
|
47
|
+
}>;
|
|
48
|
+
export type TokenCount = typeof TokenCount.Type;
|
|
49
|
+
/** The model attribute of a response with no model (a host's own `LanguageModel`, a planned step). */
|
|
50
|
+
export declare const UNKNOWN_MODEL = "unknown";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
import type { ModelPrice, TokenUsage } from "@xandreed/core";
|
|
3
|
+
import type { TokenCount } from "./telemetry.entity.js";
|
|
4
|
+
/**
|
|
5
|
+
* A response's tokens by billed kind (`usageBreakdown`), the kinds it billed
|
|
6
|
+
* only, in a fixed order: input, cache_read, cache_write, reasoning, output.
|
|
7
|
+
* For a usage that holds core's invariants the counts sum to `totalTokens`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const tokenCounts: (usage: TokenUsage) => ReadonlyArray<TokenCount>;
|
|
10
|
+
/** The model attribute of a response: its model, or `"unknown"` when it names none. */
|
|
11
|
+
export declare const modelAttribute: (model: Option.Option<string>) => string;
|
|
12
|
+
/** A model's price, when the host gave one for it. */
|
|
13
|
+
export declare const priceOf: (prices: Readonly<Record<string, ModelPrice>>, model: Option.Option<string>) => Option.Option<ModelPrice>;
|
|
14
|
+
/** What a response cost at the host's prices, when its model has one. */
|
|
15
|
+
export declare const responseCost: (prices: Readonly<Record<string, ModelPrice>>, model: Option.Option<string>, usage: TokenUsage) => Option.Option<number>;
|
|
16
|
+
/**
|
|
17
|
+
* A metric's attributes: the host's constant ones, then the metric's own
|
|
18
|
+
* (which win, so a host attribute never renames what a series counts).
|
|
19
|
+
*/
|
|
20
|
+
export declare const metricAttributesOf: (host: Readonly<Record<string, string>>, own: Readonly<Record<string, string>>) => Readonly<Record<string, string>>;
|
|
21
|
+
/** A response's usage as span event attributes: the counts under efferent's names, by kind. */
|
|
22
|
+
export declare const usageEventAttributes: (usage: TokenUsage) => Record<string, number>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
import { costOf, usageBreakdown } from "@xandreed/core";
|
|
3
|
+
import { UNKNOWN_MODEL } from "./telemetry.entity.js";
|
|
4
|
+
export const tokenCounts = (usage) => {
|
|
5
|
+
const kinds = usageBreakdown(usage);
|
|
6
|
+
return [
|
|
7
|
+
["input", kinds.input],
|
|
8
|
+
["cache_read", kinds.cacheRead],
|
|
9
|
+
["cache_write", kinds.cacheWrite],
|
|
10
|
+
["reasoning", kinds.reasoning],
|
|
11
|
+
["output", kinds.output]
|
|
12
|
+
].filter(([, count]) => count > 0).map(([kind, count]) => ({ kind, count }));
|
|
13
|
+
}, modelAttribute = (model) => Option.getOrElse(model, () => UNKNOWN_MODEL), priceOf = (prices, model) => Option.flatMap(model, (name) => Object.hasOwn(prices, name) ? Option.fromNullishOr(prices[name]) : Option.none()), responseCost = (prices, model, usage) => Option.map(priceOf(prices, model), (price) => costOf(usage, price)), metricAttributesOf = (host, own) => ({ ...host, ...own }), usageEventAttributes = (usage) => ({
|
|
14
|
+
"efferent.usage.input_tokens": usage.inputTokens,
|
|
15
|
+
"efferent.usage.output_tokens": usage.outputTokens,
|
|
16
|
+
"efferent.usage.cache_read_tokens": usage.cacheReadTokens,
|
|
17
|
+
"efferent.usage.cache_write_tokens": usage.cacheWriteTokens,
|
|
18
|
+
"efferent.usage.reasoning_tokens": usage.reasoningTokens,
|
|
19
|
+
"efferent.usage.total_tokens": usage.totalTokens
|
|
20
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { ModelPrice } from "@xandreed/core";
|
|
3
|
+
export const TelemetryOptions = Schema.Struct({
|
|
4
|
+
prices: Schema.Record(Schema.String, ModelPrice),
|
|
5
|
+
metricAttributes: Schema.Record(Schema.String, Schema.String),
|
|
6
|
+
spanEvents: Schema.Boolean
|
|
7
|
+
}), telemetryDefaults = { prices: {}, metricAttributes: {}, spanEvents: !0 }, TokenKind = Schema.Literals(["input", "cache_read", "cache_write", "reasoning", "output"]), TokenCount = Schema.Struct({
|
|
8
|
+
kind: TokenKind,
|
|
9
|
+
count: Schema.Number
|
|
10
|
+
}), UNKNOWN_MODEL = "unknown";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { RunContext, TurnEvents } from "@xandreed/core";
|
|
3
|
+
import type { TelemetryOptions } from "./telemetry.entity.js";
|
|
4
|
+
/**
|
|
5
|
+
* The turn's telemetry: one inline subscription to the turn's events that
|
|
6
|
+
* records the plugin's metrics (`TelemetryMetrics`) from them, and, with
|
|
7
|
+
* `spanEvents`, writes to the span current where the layer is built. Under
|
|
8
|
+
* `Agent.turn` that span is the turn's `agent.turn` (the turn-dependent
|
|
9
|
+
* plugins are built inside it); in a host's own composition it is whatever
|
|
10
|
+
* span encloses the build, `traceTurn`'s when the host uses it (it must
|
|
11
|
+
* stay open while the turn runs: a tracer drops what is written to a span
|
|
12
|
+
* that ended). Whatever span publishes an event, it lands there. On that span
|
|
13
|
+
* it sets `efferent.session.id`, `efferent.run.id` and `efferent.turn` when
|
|
14
|
+
* built, adds an event per response with usage (`efferent.step.usage`), per
|
|
15
|
+
* refused call (`efferent.tool.refused`) and per stream fallback
|
|
16
|
+
* (`efferent.stream.fallback`), and keeps the turn's running cost in
|
|
17
|
+
* `efferent.cost` (priced responses only). A response with no usage (a
|
|
18
|
+
* host-planned step 0) records nothing. Recording never fails the turn.
|
|
19
|
+
*/
|
|
20
|
+
export declare const TurnTelemetryLive: (options?: Partial<TelemetryOptions>) => Layer.Layer<never, never, RunContext | TurnEvents>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Clock, Duration, Effect, Layer, Match, Metric, Option, Ref } from "effect";
|
|
2
|
+
import { RunContext, TurnEvents } from "@xandreed/core";
|
|
3
|
+
import { TelemetryMetrics } from "./telemetry-metrics.js";
|
|
4
|
+
import { telemetryDefaults } from "./telemetry.entity.js";
|
|
5
|
+
import { metricAttributesOf, modelAttribute, responseCost, tokenCounts, usageEventAttributes } from "./telemetry.entity.functions.js";
|
|
6
|
+
const RECORDED = ["assistant.message", "tool.completed", "tool.refused", "step.ended", "stream.fallback"], isRecorded = (event) => RECORDED.includes(event._tag);
|
|
7
|
+
export const TurnTelemetryLive = (options = {}) => Layer.effectDiscard(Effect.gen(function* () {
|
|
8
|
+
const config = { ...telemetryDefaults, ...options }, run = yield* RunContext, events = yield* TurnEvents, span = config.spanEvents ? yield* Effect.option(Effect.currentSpan) : Option.none(), spent = yield* Ref.make(0), onSpan = (write) => Option.match(span, {
|
|
9
|
+
onNone: () => Effect.void,
|
|
10
|
+
onSome: (target) => Clock.currentTimeNanos.pipe(Effect.map((now) => write(target, now)))
|
|
11
|
+
}), count = (metric, own, input) => Metric.update(Metric.withAttributes(metric, metricAttributesOf(config.metricAttributes, own)), input), record = (event) => Match.valueTags(event, {
|
|
12
|
+
"assistant.message": (message) => message.usage.totalTokens === 0 ? Effect.void : Effect.gen(function* () {
|
|
13
|
+
const model = modelAttribute(message.model);
|
|
14
|
+
yield* Effect.forEach(tokenCounts(message.usage), (tokens) => count(TelemetryMetrics.tokens, { "efferent.model": model, "efferent.token.kind": tokens.kind }, tokens.count), { discard: !0 });
|
|
15
|
+
const cost = responseCost(config.prices, message.model, message.usage), total = yield* Option.match(cost, {
|
|
16
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
17
|
+
onSome: (amount) => count(TelemetryMetrics.cost, { "efferent.model": model }, amount).pipe(Effect.andThen(Ref.updateAndGet(spent, (sum) => sum + amount)), Effect.map(Option.some))
|
|
18
|
+
});
|
|
19
|
+
yield* onSpan((target, now) => {
|
|
20
|
+
target.event("efferent.step.usage", now, {
|
|
21
|
+
"efferent.step": message.step,
|
|
22
|
+
"efferent.model": model,
|
|
23
|
+
...usageEventAttributes(message.usage),
|
|
24
|
+
...Option.match(cost, { onNone: () => ({}), onSome: (amount) => ({ "efferent.cost": amount }) })
|
|
25
|
+
});
|
|
26
|
+
Option.match(total, { onNone: () => {
|
|
27
|
+
return;
|
|
28
|
+
}, onSome: (sum) => target.attribute("efferent.cost", sum) });
|
|
29
|
+
});
|
|
30
|
+
}),
|
|
31
|
+
"tool.completed": (completed) => {
|
|
32
|
+
const own = { "efferent.tool": completed.tool, "efferent.ok": String(completed.ok) };
|
|
33
|
+
return count(TelemetryMetrics.toolCalls, own, 1).pipe(Effect.andThen(count(TelemetryMetrics.toolDuration, own, Duration.millis(completed.durationMs))));
|
|
34
|
+
},
|
|
35
|
+
"tool.refused": (refused) => count(TelemetryMetrics.toolRefused, { "efferent.tool": refused.tool, "efferent.refusal.reason": refused.reason }, 1).pipe(Effect.andThen(onSpan((target, now) => target.event("efferent.tool.refused", now, {
|
|
36
|
+
"efferent.step": refused.step,
|
|
37
|
+
"efferent.tool": refused.tool,
|
|
38
|
+
"efferent.tool.invocation_id": refused.invocationId,
|
|
39
|
+
"efferent.refusal.reason": refused.reason
|
|
40
|
+
})))),
|
|
41
|
+
"step.ended": (ended) => count(TelemetryMetrics.steps, { "efferent.step.status": ended.status }, 1),
|
|
42
|
+
"stream.fallback": (fallback) => count(TelemetryMetrics.streamFallbacks, {}, 1).pipe(Effect.andThen(onSpan((target, now) => target.event("efferent.stream.fallback", now, {
|
|
43
|
+
"efferent.step": fallback.step,
|
|
44
|
+
"efferent.stream.fallback.message": fallback.message
|
|
45
|
+
}))))
|
|
46
|
+
});
|
|
47
|
+
yield* onSpan((target) => {
|
|
48
|
+
target.attribute("efferent.session.id", run.session.id);
|
|
49
|
+
target.attribute("efferent.run.id", run.runId);
|
|
50
|
+
target.attribute("efferent.turn", run.admitted.turn);
|
|
51
|
+
});
|
|
52
|
+
yield* events.subscribe((event) => isRecorded(event) ? Option.some(event) : Option.none(), record);
|
|
53
|
+
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xandreed/plugin-telemetry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": ">=24",
|
|
6
|
+
"bun": ">=1.3.0"
|
|
7
|
+
},
|
|
4
8
|
"type": "module",
|
|
9
|
+
"description": "Usage, cost and tool telemetry for efferent turns, and OTLP/HTTP export of traces and metrics over Effect's own exporters (Node 24 and Bun).",
|
|
5
10
|
"main": "./dist/index.js",
|
|
6
11
|
"exports": {
|
|
7
12
|
".": {
|
|
@@ -10,15 +15,8 @@
|
|
|
10
15
|
}
|
|
11
16
|
},
|
|
12
17
|
"dependencies": {
|
|
13
|
-
"@xandreed/core": "0.
|
|
14
|
-
"effect": "
|
|
15
|
-
"@effect/opentelemetry": "^0.63.0",
|
|
16
|
-
"@effect/platform": "0.96.1",
|
|
17
|
-
"@effect/platform-bun": "^0.89.0",
|
|
18
|
-
"@opentelemetry/exporter-metrics-otlp-http": "^0.219.0",
|
|
19
|
-
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
|
20
|
-
"@opentelemetry/sdk-metrics": "^2.8.0",
|
|
21
|
-
"@opentelemetry/sdk-trace-base": "^2.8.0"
|
|
18
|
+
"@xandreed/core": "0.14.0",
|
|
19
|
+
"effect": "4.0.7"
|
|
22
20
|
},
|
|
23
21
|
"private": false,
|
|
24
22
|
"types": "./dist/index.d.ts",
|
|
@@ -29,8 +27,5 @@
|
|
|
29
27
|
"README.md",
|
|
30
28
|
"LICENSE"
|
|
31
29
|
],
|
|
32
|
-
"engines": {
|
|
33
|
-
"bun": ">=1.3.0"
|
|
34
|
-
},
|
|
35
30
|
"license": "MIT"
|
|
36
31
|
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { Layer } from "effect";
|
|
2
|
-
/**
|
|
3
|
-
* The observability edge — two layers every agent main composes:
|
|
4
|
-
*
|
|
5
|
-
* - `TracingLive(serviceName)` exports the spans the kernel already emits
|
|
6
|
-
* (`engine.run` / `engine.turn` / `providers.generate`) over OTLP HTTP to
|
|
7
|
-
* the local stack (`bun run obs:up`, grafana on :3000). The exporter reads
|
|
8
|
-
* the standard `OTEL_EXPORTER_OTLP_ENDPOINT` env itself and defaults to
|
|
9
|
-
* `http://localhost:4318`; when no collector is listening, batch exports
|
|
10
|
-
* fail SILENTLY (no diag logger is registered) — always-on costs nothing.
|
|
11
|
-
*
|
|
12
|
-
* - `FileLoggerLive(path)` routes Effect's logger to an append-only logfmt
|
|
13
|
-
* file. The TUI previously SILENCED all logs (any console write corrupts
|
|
14
|
-
* the alt screen), which meant a mid-run failure left no trace anywhere —
|
|
15
|
-
* a session that "just died" was undiagnosable. Now it's on disk.
|
|
16
|
-
*/
|
|
17
|
-
export declare const TracingLive: (serviceName: string) => Layer.Layer<never>;
|
|
18
|
-
export declare const FileLoggerLive: (path: string) => Layer.Layer<never>;
|
|
19
|
-
/** Add the file sink while retaining the driver's console logger (headless
|
|
20
|
-
* mode needs both a live stderr narrative and crash-forensics on disk). */
|
|
21
|
-
export declare const FileLoggerAddLive: (path: string) => Layer.Layer<never>;
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { dirname } from "node:path";
|
|
2
|
-
import { NodeSdk } from "@effect/opentelemetry";
|
|
3
|
-
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
4
|
-
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
5
|
-
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
|
6
|
-
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
7
|
-
import { FileSystem, PlatformLogger } from "@effect/platform";
|
|
8
|
-
import { BunFileSystem } from "@effect/platform-bun";
|
|
9
|
-
import { Effect, Layer, Logger } from "effect";
|
|
10
|
-
export const TracingLive = (serviceName) => NodeSdk.layer(() => ({
|
|
11
|
-
resource: { serviceName },
|
|
12
|
-
spanProcessor: [new BatchSpanProcessor(new OTLPTraceExporter)],
|
|
13
|
-
metricReader: new PeriodicExportingMetricReader({
|
|
14
|
-
exporter: new OTLPMetricExporter,
|
|
15
|
-
exportIntervalMillis: 5000
|
|
16
|
-
})
|
|
17
|
-
}));
|
|
18
|
-
const fileLogger = (path) => Effect.gen(function* () {
|
|
19
|
-
yield* (yield* FileSystem.FileSystem).makeDirectory(dirname(path), { recursive: !0 }).pipe(Effect.orElseSucceed(() => {
|
|
20
|
-
return;
|
|
21
|
-
}));
|
|
22
|
-
return yield* PlatformLogger.toFile(Logger.logfmtLogger, path, { flag: "a" });
|
|
23
|
-
}).pipe(Effect.orElseSucceed(() => Logger.none));
|
|
24
|
-
export const FileLoggerLive = (path) => Logger.replaceScoped(Logger.defaultLogger, fileLogger(path)).pipe(Layer.provide(BunFileSystem.layer)), FileLoggerAddLive = (path) => Logger.addScoped(fileLogger(path)).pipe(Layer.provide(BunFileSystem.layer));
|