@telorun/sdk 0.49.0 → 0.54.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.
@@ -0,0 +1,115 @@
1
+ import type { LogRecord } from "./log-record.js";
2
+
3
+ /**
4
+ * The `Telo.Sink` capability contract — `kernel/specs/logging.md` §10.
5
+ *
6
+ * This lives in the SDK rather than the kernel because §10.2 makes the sink set
7
+ * **open to the ecosystem**: a third party ships a sink by publishing a module
8
+ * whose kind extends `Telo.LogSink`. That module is an ordinary module author's
9
+ * artifact, so the contract it implements belongs on the module-author surface,
10
+ * not behind a kernel-internal import.
11
+ *
12
+ * The logger writes to a sink through this contract directly and **never**
13
+ * through `ctx.invoke`: per-record dispatch is far too slow for a logging hot
14
+ * path, and the dispatch chokepoint emits trace events, so logging through it
15
+ * would generate telemetry from inside the telemetry path.
16
+ *
17
+ * The contract is deliberately payload-opaque — no filtering, no encoding —
18
+ * which is what lets a future `Telo.TraceSink` reuse the capability with a
19
+ * different record type. Log-specific configuration lives on the
20
+ * `Telo.LogSink` abstract instead.
21
+ */
22
+
23
+ export type DropCause = "buffer_full" | "sampled" | "encode_failure" | "sink_error";
24
+
25
+ /** Policy for a saturated buffer (§10.3). A runtime that cannot honour `block`
26
+ * rejects the manifest at load rather than silently substituting a dropping
27
+ * policy. */
28
+ export type OnFull = "block" | "drop_new" | "drop_old";
29
+
30
+ export interface SinkBufferPolicy {
31
+ /** Bounded. Never unbounded. */
32
+ buffer: number;
33
+ onFull: OnFull;
34
+ /** Max time a record may sit buffered, in milliseconds. */
35
+ flushIntervalMs: number;
36
+ }
37
+
38
+ export const DEFAULT_BUFFER_POLICY: SinkBufferPolicy = {
39
+ buffer: 8192,
40
+ onFull: "drop_new",
41
+ flushIntervalMs: 1000,
42
+ };
43
+
44
+ export interface LogSinkInstance {
45
+ /** Identity for drop accounting (§10.4): the resource name for a `!ref`, or
46
+ * kind plus position for an inline definition. */
47
+ readonly sinkId: string;
48
+
49
+ /** This sink's own fan-out filter, applied *after* the record is created. It
50
+ * never decides whether a record is created at all — that is the pipeline's
51
+ * minimum-level gate. */
52
+ readonly level: number;
53
+
54
+ /**
55
+ * Whether the sink can be drained to its destination from inside a
56
+ * synchronous call, with no scheduler turn. A file descriptor write can; a
57
+ * network round-trip cannot, and neither can a transport living on another
58
+ * thread — the producer cannot drain a queue it does not own.
59
+ *
60
+ * A capability tier, not a language carve-out: the same rule makes an OTLP
61
+ * sink best-effort in Rust and Go, where blocking a producer thread is
62
+ * possible but still would not make a round-trip synchronous.
63
+ */
64
+ readonly syncFlushable: boolean;
65
+
66
+ /** Accept a record. MUST NOT throw — a sink failure is reported out-of-band
67
+ * and counted, never propagated to the caller (§8.4). */
68
+ write(record: LogRecord): void;
69
+
70
+ /** Drain asynchronously. */
71
+ flush(): Promise<void>;
72
+
73
+ /** Drain to completion before returning. A no-op when {@link syncFlushable}
74
+ * is `false`; the `fatal` path initiates those sinks' flushes without
75
+ * waiting, because blocking on a sink it cannot synchronously drain is a
76
+ * deadlock on an event loop, not durability. */
77
+ flushSync(): void;
78
+
79
+ /** Release the destination. Called during teardown, after the final flush. */
80
+ close(): Promise<void>;
81
+ }
82
+
83
+ /**
84
+ * The pipeline surface a sink controller reaches for — attach, detach, resolve a
85
+ * level, count a drop. Deliberately narrow: everything else about the pipeline
86
+ * stays private to the runtime, so a third-party sink depends on this and
87
+ * nothing deeper.
88
+ */
89
+ export interface LoggingHost {
90
+ attach(sink: LogSinkInstance): void;
91
+ detach(sink: LogSinkInstance): void;
92
+ /** Resolve a sink's declared `level:` to a severity number, falling back to
93
+ * the effective scope threshold when the sink declares none (§12.1). */
94
+ levelFor(level: string | undefined): number;
95
+ /** Count `count` dropped records against this sink so §10.4's accounting stays
96
+ * complete. `count` defaults to 1; a sink that loses a whole batch at once
97
+ * (an OTLP export failure) passes the batch size so the total is not
98
+ * undercounted to one-per-failure. */
99
+ recordDrop(sinkId: string, cause: DropCause, count?: number): void;
100
+ }
101
+
102
+ /** The diagnostic §10.3 requires when a runtime cannot honour `on_full: block`.
103
+ * Rejecting is deliberate: `on_full` exists so an operator can state durability
104
+ * intent, and silently substituting a dropping policy hands back the opposite
105
+ * guarantee — discovered from a gap in an audit trail rather than from an
106
+ * error. */
107
+ export function blockUnsupportedMessage(sinkId: string): string {
108
+ return (
109
+ `Sink "${sinkId}": on_full: block is not supported by this runtime ` +
110
+ `(single-threaded event loop — blocking the producer would stall the writer). ` +
111
+ `Use \`drop_new\` or \`drop_old\`, or move this sink to a worker thread.`
112
+ );
113
+ }
114
+
115
+ export const BLOCK_UNSUPPORTED = "ERR_LOG_SINK_ON_FULL_UNSUPPORTED";
package/src/logger.ts ADDED
@@ -0,0 +1,125 @@
1
+ import type { AnyValue, LogAttributes } from "./log-record.js";
2
+ import type { SeverityNumber } from "./log-severity.js";
3
+
4
+ /**
5
+ * The logger surface every Telo runtime exposes — `kernel/specs/logging.md` §8.
6
+ *
7
+ * Reached ambiently as `ctx.log`. The logger is ambient rather than a resource
8
+ * because it must work before any resource initializes; its *sinks* are
9
+ * resources, which is what keeps the destination set open to the ecosystem.
10
+ */
11
+
12
+ /**
13
+ * A value resolved only on the emit path (§8.2) — slog's `LogValuer`, zap's
14
+ * `ObjectMarshaler`, `tracing`'s `Value`. A deferred value attached to a
15
+ * suppressed record is never resolved, so an expensive rendering costs nothing
16
+ * below the threshold. This is RECOMMENDED sugar and does **not** substitute for
17
+ * {@link Logger.enabled}, which is the only mechanism that avoids evaluating a
18
+ * call's *arguments*.
19
+ */
20
+ export interface LogValuer {
21
+ toLogValue(): AnyValue;
22
+ }
23
+
24
+ export type LogAttributeInput = AnyValue | LogValuer;
25
+ export type LogAttributesInput = Record<string, LogAttributeInput>;
26
+
27
+ /** Per-record extras that are top-level record fields rather than attributes.
28
+ * Kept out of the attribute map so they cannot collide with a reserved key. */
29
+ export interface LogOptions {
30
+ /** Any thrown value. Normalized to the record's `error` (§4.2), with the
31
+ * `cause` chain bounded per §6.3. */
32
+ error?: unknown;
33
+ /** Identifies a class of event; max 256 chars. Bridges to the event bus. */
34
+ eventName?: string;
35
+ /** When the event occurred, if earlier than the moment `log()` was called —
36
+ * set by a bridge, which also stamps `observedTimestamp` (§13.3). */
37
+ timestamp?: bigint;
38
+ /** The original source spelling of the level, preserved when bridging a level
39
+ * Telo does not name (§5.1). Defaults to the canonical short name. */
40
+ severityText?: string;
41
+ }
42
+
43
+ export interface Logger {
44
+ /**
45
+ * Whether a record at this severity would reach any sink. The load-bearing
46
+ * performance primitive: guard an expensive call with it so the *arguments*
47
+ * are never evaluated.
48
+ *
49
+ * Never blocks, never throws. The result is **not static** — it changes when
50
+ * configuration changes or a sink attaches or detaches (§12.4), so callers
51
+ * re-check per emission rather than caching a boolean.
52
+ */
53
+ enabled(severity: SeverityNumber): boolean;
54
+
55
+ /**
56
+ * Emit a record. Never throws, under any condition, including sink failure —
57
+ * a sink failure is reported on the fallback diagnostic stream and counted,
58
+ * never propagated and never swallowed (§8.4).
59
+ */
60
+ log(
61
+ severity: SeverityNumber,
62
+ message: string,
63
+ attributes?: LogAttributesInput,
64
+ options?: LogOptions,
65
+ ): void;
66
+
67
+ /**
68
+ * A child logger whose bound attributes are merged into every record it emits.
69
+ * Record attributes override bound attributes. Binding is O(1) amortized: the
70
+ * merge happens once here, never per record.
71
+ */
72
+ with(attributes: LogAttributesInput): Logger;
73
+
74
+ /** Drain every attached sink. Bounded by the caller; see §10.5. */
75
+ flush(): Promise<void>;
76
+
77
+ trace(message: string, attributes?: LogAttributesInput, options?: LogOptions): void;
78
+ debug(message: string, attributes?: LogAttributesInput, options?: LogOptions): void;
79
+ info(message: string, attributes?: LogAttributesInput, options?: LogOptions): void;
80
+ warn(message: string, attributes?: LogAttributesInput, options?: LogOptions): void;
81
+ error(message: string, attributes?: LogAttributesInput, options?: LogOptions): void;
82
+ /**
83
+ * Emits at severity 21. Severity never implies control flow (§5, D5): `fatal`
84
+ * does **not** terminate the process, exit, or panic — it triggers an
85
+ * immediate flush, synchronous on every sink that supports it and best-effort
86
+ * on the rest (§10.5).
87
+ */
88
+ fatal(message: string, attributes?: LogAttributesInput, options?: LogOptions): void;
89
+ }
90
+
91
+ /** Resolve a {@link LogValuer} if the value is one, else pass it through. */
92
+ export function isLogValuer(value: unknown): value is LogValuer {
93
+ return (
94
+ typeof value === "object" &&
95
+ value !== null &&
96
+ typeof (value as LogValuer).toLogValue === "function"
97
+ );
98
+ }
99
+
100
+ /** A logger that discards everything. Used where a logger is structurally
101
+ * required before one is available, and by tests that assert silence. */
102
+ export const NOOP_LOGGER: Logger = {
103
+ enabled: () => false,
104
+ log: () => {},
105
+ with: () => NOOP_LOGGER,
106
+ flush: async () => {},
107
+ trace: () => {},
108
+ debug: () => {},
109
+ info: () => {},
110
+ warn: () => {},
111
+ error: () => {},
112
+ fatal: () => {},
113
+ };
114
+
115
+ /** The bound-attribute merge of {@link Logger.with}, exposed so a runtime's
116
+ * child-logger implementation and its conformance vectors share one definition
117
+ * of "record attributes win". */
118
+ export function mergeBoundAttributes(
119
+ bound: LogAttributes | undefined,
120
+ record: LogAttributes | undefined,
121
+ ): LogAttributes | undefined {
122
+ if (!bound) return record;
123
+ if (!record) return bound;
124
+ return { ...bound, ...record };
125
+ }
@@ -0,0 +1,81 @@
1
+ import type { ModuleContext } from "./module-context.js";
2
+ import type { KindRef } from "./ref.js";
3
+ import { RuntimeError } from "./types.js";
4
+
5
+ /** The slice of `ResourceContext` needed to resolve a reference. */
6
+ export interface RefResolveContext {
7
+ readonly moduleContext: ModuleContext;
8
+ }
9
+
10
+ /**
11
+ * Resolve a `!ref` config field to a live instance of `T`. Controllers reach
12
+ * this as `ctx.resolveRef(value, guard, describe, expects)`; the standalone form
13
+ * is for callers holding only a `{ moduleContext }` slice rather than a full
14
+ * `ResourceContext`.
15
+ *
16
+ * Phase 5 injection normally replaces the slot with the live `ResourceInstance`
17
+ * before `init()` — local and cross-module refs alike, since injection resolves
18
+ * an aliased ref through the import's export table (and defers, rather than
19
+ * leaving a raw ref, when the import hasn't published its exports yet). So the
20
+ * common path here is the guard short-circuit.
21
+ *
22
+ * A raw {@link KindRef} still reaches a controller where injection does not
23
+ * reach the slot: a kind whose definition yields no field map, or a ref the
24
+ * controller obtained itself via `ctx.ensureKindRef`. Both are gaps worth
25
+ * closing in the kernel — until they are, both shapes must be accepted here, and
26
+ * an aliased ref routes through the import's exported scope because a bare local
27
+ * lookup would miss it.
28
+ *
29
+ * `guard` decides what counts as the right kind of instance — a duck-type check
30
+ * on the methods the caller will actually invoke, so a mis-wired ref fails with a
31
+ * clear message here rather than as `undefined is not a function` later.
32
+ * `describe` labels the owning resource and slot; `expects` names the contract
33
+ * the slot wants — the slot's own `x-telo-ref` string (`std/cache#Store`) — so
34
+ * the message says what was missing, not just that something was.
35
+ *
36
+ * @example
37
+ * const store = resolveRefInstance(
38
+ * this.resource.store, this.ctx, isKvStore,
39
+ * () => `Idempotency.Once "${name}": 'store'`, "std/kv-store#Store",
40
+ * );
41
+ */
42
+ export function resolveRefInstance<T>(
43
+ value: unknown,
44
+ ctx: RefResolveContext,
45
+ guard: (candidate: unknown) => candidate is T,
46
+ describe: () => string,
47
+ expects?: string,
48
+ ): T {
49
+ // Phase-5-injected: already the instance.
50
+ if (guard(value)) return value;
51
+
52
+ const target = expects ? `resource satisfying \`${expects}\`` : "resource";
53
+ if (value === undefined || value === null) {
54
+ throw new RuntimeError("ERR_REF_REQUIRED", `${describe()} is required — reference a ${target}.`);
55
+ }
56
+
57
+ const ref = value as Partial<KindRef<T>>;
58
+ if (typeof ref.name !== "string") {
59
+ throw new RuntimeError(
60
+ "ERR_REF_UNRESOLVED",
61
+ `${describe()} must be a \`!ref\` to a ${target}.`,
62
+ );
63
+ }
64
+
65
+ // `Self` names the declaring library's own scope, so it resolves locally —
66
+ // it is an alias that crosses no import boundary.
67
+ const instance =
68
+ ref.alias && ref.alias !== "Self"
69
+ ? ctx.moduleContext.resolveImportedInstance(ref.alias, ref.name)
70
+ : ctx.moduleContext.getInstance(ref.name);
71
+
72
+ if (!guard(instance)) {
73
+ const label = ref.alias ? `${ref.alias}.${ref.name}` : ref.name;
74
+ throw new RuntimeError(
75
+ "ERR_REF_UNRESOLVED",
76
+ `${describe()} reference '${label}' did not resolve to a ${target}` +
77
+ `${instance === undefined ? " (nothing is registered under that name)" : ""}.`,
78
+ );
79
+ }
80
+ return instance;
81
+ }
@@ -1,8 +1,11 @@
1
1
  import type { CancellationSource, InvokeContext, OpenSpan, OpenSpanOptions } from "./cancellation.js";
2
2
  import { ControllerContext } from "./controller-context.js";
3
+ import type { Logger } from "./logger.js";
4
+ import type { LoggingHost } from "./log-sink.js";
3
5
  import { ControllerPolicy } from "./controller-policy.js";
4
6
  import { EvaluationContext } from "./evaluation-context.js";
5
7
  import { ModuleContext } from "./module-context.js";
8
+ import type { KindRef } from "./ref.js";
6
9
  import { ResourceInstance } from "./resource-instance.js";
7
10
  import { ResourceManifest } from "./resource-manifest.js";
8
11
  import { RuntimeResource } from "./runtime-resource.js";
@@ -84,7 +87,35 @@ export interface ResourceContext extends ControllerContext {
84
87
  spawnChildContext(): EvaluationContext;
85
88
  transientChild(context: Record<string, any>): EvaluationContext;
86
89
  withManifests<T>(manifests: any[], fn: () => T): T;
90
+ /**
91
+ * Normalize a nested slot value to a {@link KindRef}. The value is an inline
92
+ * definition (`{ kind, …config }`), an already-normalized `{ kind, name }`
93
+ * ref, or a `!ref` sentinel. An inline definition is *registered* into this
94
+ * module's scope first — minting `resourceName` (or a generated one) as its
95
+ * name — so the returned ref always points at a resource the kernel knows.
96
+ *
97
+ * The inverse of {@link resolveRef}: this goes slot value → ref, that goes
98
+ * ref → live instance. Controllers that dispatch through
99
+ * `invokeResolved(kind, name, …)` want the ref, so the invocation keeps its
100
+ * identity for tracing and error wrapping.
101
+ */
102
+ ensureKindRef(value: any, resourceName?: string): KindRef;
103
+ /** @deprecated Renamed to {@link ensureKindRef} — it produces a reference
104
+ * (registering an inline definition on the way), it does not resolve one. */
87
105
  resolveChildren(resource: any, resourceName?: string): { kind: string; name: string };
106
+ /**
107
+ * Resolve a `!ref` config field to a live instance of `T`. See
108
+ * {@link resolveRefInstance} — this is the same resolution, reached through
109
+ * the context a controller already holds. `expects` names the contract the
110
+ * slot wants — its `x-telo-ref` string (`std/cache#Store`) — so a mis-wire
111
+ * says what was missing.
112
+ */
113
+ resolveRef<T>(
114
+ value: unknown,
115
+ guard: (candidate: unknown) => candidate is T,
116
+ describe: () => string,
117
+ expects?: string,
118
+ ): T;
88
119
  validateSchema(value: any, schema: any): void;
89
120
  createSchemaValidator(schema: any): DataValidator;
90
121
  registerSchema(name: string, schema: object): void;
@@ -131,6 +162,28 @@ export interface ResourceContext extends ControllerContext {
131
162
  * manifests. Use this when you need the full kind surface area visible from
132
163
  * the module. */
133
164
  loadManifests(url: string): Promise<ResourceManifest[]>;
165
+ /**
166
+ * The structured logger for this resource — `kernel/specs/logging.md` §13.2.
167
+ *
168
+ * Ambient rather than a resource (D3), because it must work before any
169
+ * resource initializes. Records are automatically stamped with this
170
+ * resource's identity, its module, its import-alias scope, and the active
171
+ * dispatch span's trace and span ids — a controller never passes those.
172
+ *
173
+ * A controller emits diagnostics **only** through this. Writing to
174
+ * stdout/stderr for diagnostic purposes is forbidden; writing to stdout as
175
+ * *data* (as the `Console` module does) is a separate, legitimate concern and
176
+ * is unaffected.
177
+ */
178
+ readonly log: Logger;
179
+ /**
180
+ * Sink attach/detach and drop accounting — the surface a `Telo.Sink`
181
+ * controller needs and nothing else. §10.2 keeps the sink set open to the
182
+ * ecosystem, so a third-party sink module reaches the pipeline through this
183
+ * rather than through a kernel-internal import. Ordinary controllers use
184
+ * {@link log}.
185
+ */
186
+ readonly logging: LoggingHost;
134
187
  readonly moduleContext: ModuleContext;
135
188
  readonly env: Record<string, string | undefined>;
136
189
  readonly stdin: NodeJS.ReadableStream;
@@ -11,8 +11,26 @@ export type ResourceInstance<TInput = Record<string, any>, TOutput = any> = Part
11
11
  init?(ctx?: ResourceContext): Promise<void>;
12
12
  teardown?(): void | Promise<void>;
13
13
  snapshot?(): Record<string, any> | Promise<Record<string, any>>;
14
+ /**
15
+ * Teardown ordering hint. Instances tear down in ascending priority — a
16
+ * higher number means *later*. Default `0`; within one priority the base
17
+ * order (reverse init) is preserved.
18
+ *
19
+ * This exists because the base order is reverse *insertion* order, which the
20
+ * multi-pass init retry can perturb, so a resource that must reliably outlive
21
+ * the rest at shutdown cannot express that through the dependency graph. Log
22
+ * sinks set {@link TEARDOWN_LAST} so they flush after every resource that
23
+ * might log while shutting down — a generic mechanism, not a logging-specific
24
+ * carve-out in the teardown path.
25
+ */
26
+ teardownPriority?: number;
14
27
  };
15
28
 
29
+ /** Teardown-last priority (see {@link ResourceInstance.teardownPriority}). Log
30
+ * sinks use it so anything logging during its own teardown still reaches a live
31
+ * destination. */
32
+ export const TEARDOWN_LAST = 1000;
33
+
16
34
  /** The kind+name an instance was resolved from. */
17
35
  export interface RefIdentity {
18
36
  kind: string;