@xandreed/plugin-telemetry 0.3.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 CHANGED
@@ -1,5 +1,5 @@
1
1
  # @xandreed/plugin-telemetry
2
2
 
3
- Efferent composable agent framework, 0.3.0. Bun on Linux.
3
+ Efferent composable agent framework, 0.14.0. Node 24 or Bun.
4
4
 
5
5
  Documentation: https://github.com/xandreeddev/efferent
package/dist/index.d.ts CHANGED
@@ -1,2 +1,8 @@
1
- export { FileLoggerAddLive, FileLoggerLive, TracingLive } from "./telemetry/telemetry.js";
2
- export { telemetryPlugin, default } from "./plugin.adapter.js";
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 { FileLoggerAddLive, FileLoggerLive, TracingLive } from "./telemetry/telemetry.js";
2
- export { telemetryPlugin, default } from "./plugin.adapter.js";
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 {};
@@ -0,0 +1,2 @@
1
+ import { Schema } from "effect";
2
+ export const OtlpProtocol = Schema.Literals(["http/protobuf", "http/json"]), OtlpCompression = Schema.Literals(["gzip", "none"]), OtlpTemporality = Schema.Literals(["cumulative", "delta"]), DEFAULT_SERVICE_NAME = "efferent";
@@ -1,2 +1,56 @@
1
- export declare const telemetryPlugin: import("@xandreed/core").Plugin;
2
- export default telemetryPlugin;
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>;
@@ -1,15 +1,13 @@
1
- import { Effect, Layer, Schema } from "effect";
2
- import { definePlugin, TurnHooks } from "@xandreed/core";
3
- import { TracingLive } from "./telemetry/telemetry.js";
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.3.0",
7
- config: Schema.Struct({ enabled: Schema.Boolean, serviceName: Schema.String }),
8
- defaults: { enabled: !1, serviceName: "efferent" },
9
- provides: [TurnHooks],
10
- layer: ({ enabled, serviceName }) => Layer.succeed(TurnHooks, {
11
- before: (input) => Effect.succeed(input),
12
- after: (input) => Effect.annotateCurrentSpan({ "session.id": input.session.id, "run.id": input.runId })
13
- }).pipe(Layer.provide(enabled ? TracingLive(serviceName) : Layer.empty))
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.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.3.0",
14
- "effect": "3.21.4",
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));