@telorun/kernel 0.30.2 → 0.32.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/dist/dependency-injection.d.ts.map +1 -1
- package/dist/dependency-injection.js +7 -1
- package/dist/dependency-injection.js.map +1 -1
- package/dist/evaluation-context.d.ts +53 -1
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +203 -30
- package/dist/evaluation-context.js.map +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +6 -0
- package/dist/events.js.map +1 -1
- package/dist/kernel.d.ts +9 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +15 -2
- package/dist/kernel.js.map +1 -1
- package/dist/module-context.d.ts +4 -1
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +77 -15
- package/dist/module-context.js.map +1 -1
- package/dist/resource-context.d.ts +2 -1
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +4 -1
- package/dist/resource-context.js.map +1 -1
- package/dist/tracing.d.ts +19 -0
- package/dist/tracing.d.ts.map +1 -0
- package/dist/tracing.js +39 -0
- package/dist/tracing.js.map +1 -0
- package/package.json +2 -2
- package/src/dependency-injection.ts +7 -1
- package/src/evaluation-context.ts +306 -35
- package/src/events.ts +5 -0
- package/src/kernel.ts +19 -2
- package/src/module-context.ts +100 -17
- package/src/resource-context.ts +7 -1
- package/src/tracing.ts +27 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Tracer } from "@telorun/sdk";
|
|
2
|
+
/**
|
|
3
|
+
* The kernel's invocation tracer: a monotonic counter plus the `enabled` gate a
|
|
4
|
+
* debug consumer flips on attach (`Kernel.setTracing`). One instance per kernel,
|
|
5
|
+
* shared by reference across the whole context tree, so invocation ids are unique
|
|
6
|
+
* within the run and `enabled` toggles everywhere at once.
|
|
7
|
+
*
|
|
8
|
+
* When `enabled` is `false` (the default), `invoke` skips id minting and the extra
|
|
9
|
+
* ALS scope entirely — tracing costs nothing until someone is watching.
|
|
10
|
+
*/
|
|
11
|
+
export declare class KernelTracer implements Tracer {
|
|
12
|
+
#private;
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
next(): number;
|
|
15
|
+
/** A fresh OTel-compatible 16-byte hex trace id. Globally unique, so a trace
|
|
16
|
+
* stays identifiable once it crosses process boundaries. */
|
|
17
|
+
newTraceId(): string;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=tracing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracing.d.ts","sourceRoot":"","sources":["../src/tracing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAE3C;;;;;;;;GAQG;AACH,qBAAa,YAAa,YAAW,MAAM;;IACzC,OAAO,UAAS;IAGhB,IAAI,IAAI,MAAM;IAKd;iEAC6D;IAC7D,UAAU,IAAI,MAAM;CAGrB"}
|
package/dist/tracing.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
2
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
3
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
4
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
5
|
+
};
|
|
6
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
7
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
8
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
9
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
10
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
11
|
+
};
|
|
12
|
+
var _KernelTracer_next;
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
/**
|
|
15
|
+
* The kernel's invocation tracer: a monotonic counter plus the `enabled` gate a
|
|
16
|
+
* debug consumer flips on attach (`Kernel.setTracing`). One instance per kernel,
|
|
17
|
+
* shared by reference across the whole context tree, so invocation ids are unique
|
|
18
|
+
* within the run and `enabled` toggles everywhere at once.
|
|
19
|
+
*
|
|
20
|
+
* When `enabled` is `false` (the default), `invoke` skips id minting and the extra
|
|
21
|
+
* ALS scope entirely — tracing costs nothing until someone is watching.
|
|
22
|
+
*/
|
|
23
|
+
export class KernelTracer {
|
|
24
|
+
constructor() {
|
|
25
|
+
this.enabled = false;
|
|
26
|
+
_KernelTracer_next.set(this, 0);
|
|
27
|
+
}
|
|
28
|
+
next() {
|
|
29
|
+
__classPrivateFieldSet(this, _KernelTracer_next, __classPrivateFieldGet(this, _KernelTracer_next, "f") + 1, "f");
|
|
30
|
+
return __classPrivateFieldGet(this, _KernelTracer_next, "f");
|
|
31
|
+
}
|
|
32
|
+
/** A fresh OTel-compatible 16-byte hex trace id. Globally unique, so a trace
|
|
33
|
+
* stays identifiable once it crosses process boundaries. */
|
|
34
|
+
newTraceId() {
|
|
35
|
+
return randomBytes(16).toString("hex");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
_KernelTracer_next = new WeakMap();
|
|
39
|
+
//# sourceMappingURL=tracing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracing.js","sourceRoot":"","sources":["../src/tracing.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAG1C;;;;;;;;GAQG;AACH,MAAM,OAAO,YAAY;IAAzB;QACE,YAAO,GAAG,KAAK,CAAC;QAChB,6BAAQ,CAAC,EAAC;IAYZ,CAAC;IAVC,IAAI;QACF,yGAAc,CAAC,MAAA,CAAC;QAChB,OAAO,uBAAA,IAAI,0BAAM,CAAC;IACpB,CAAC;IAED;iEAC6D;IAC7D,UAAU;QACR,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;IACzC,CAAC;CACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/kernel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Telo Runtime - A lightweight, polyglot execution host.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"@types/node": "^20.0.0",
|
|
60
60
|
"typescript": "^5.0.0",
|
|
61
61
|
"vitest": "^2.1.8",
|
|
62
|
-
"@telorun/sdk": "0.
|
|
62
|
+
"@telorun/sdk": "0.32.0"
|
|
63
63
|
},
|
|
64
64
|
"optionalDependencies": {
|
|
65
65
|
"esbuild": "^0.28.1"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ResourceInstance, ResourceManifest, RuntimeError } from "@telorun/sdk";
|
|
1
|
+
import { ResourceInstance, ResourceManifest, RuntimeError, stampRefIdentity } from "@telorun/sdk";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Walks `resource` following `fieldPath` (dot notation, `[]` = array traversal,
|
|
@@ -29,6 +29,12 @@ export function injectAtPath(
|
|
|
29
29
|
`Cross-module reference '${alias}.${String(ref.name)}' is not available yet (import not initialized)`,
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
|
+
// Tag the instance with the kind+name it resolved from, so a consumer that
|
|
33
|
+
// holds only the bare instance (an invoke-step target) can dispatch it
|
|
34
|
+
// through the traced chokepoint rather than calling `.invoke()` directly.
|
|
35
|
+
if (instance && typeof ref.kind === "string" && typeof ref.name === "string") {
|
|
36
|
+
stampRefIdentity(instance, ref.kind, ref.name);
|
|
37
|
+
}
|
|
32
38
|
return instance;
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type InstanceFactory,
|
|
11
11
|
type InvokeContext,
|
|
12
12
|
type LifecycleState,
|
|
13
|
+
type OpenSpan,
|
|
14
|
+
type OpenSpanOptions,
|
|
13
15
|
type PreInitHook,
|
|
14
16
|
type ResourceDefinition,
|
|
15
17
|
type ResourceInstance,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
type RuntimeDiagnostic,
|
|
18
20
|
type ScopeContext,
|
|
19
21
|
type ScopeHandle,
|
|
22
|
+
type Tracer,
|
|
20
23
|
} from "@telorun/sdk";
|
|
21
24
|
import { RuntimeError } from "@telorun/sdk";
|
|
22
25
|
|
|
@@ -179,6 +182,12 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
179
182
|
*/
|
|
180
183
|
getDefinition?: (kind: string) => ResourceDefinition | undefined;
|
|
181
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Per-kernel invocation tracer. Set by the kernel on the root context and
|
|
187
|
+
* propagated through spawnChild(); gates invocation-id minting in runInvoke().
|
|
188
|
+
*/
|
|
189
|
+
tracer?: Tracer;
|
|
190
|
+
|
|
182
191
|
constructor(
|
|
183
192
|
readonly source: string,
|
|
184
193
|
context: Record<string, unknown>,
|
|
@@ -259,6 +268,9 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
259
268
|
if (this.getDefinition && !child.getDefinition) {
|
|
260
269
|
child.getDefinition = this.getDefinition;
|
|
261
270
|
}
|
|
271
|
+
if (this.tracer && !child.tracer) {
|
|
272
|
+
child.tracer = this.tracer;
|
|
273
|
+
}
|
|
262
274
|
return child;
|
|
263
275
|
}
|
|
264
276
|
|
|
@@ -594,6 +606,50 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
594
606
|
return this.runInvoke(kind, name, instance, inputs, ctx);
|
|
595
607
|
}
|
|
596
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Build the structured trace payload every capability dispatch emits. The
|
|
611
|
+
* event *name* stays human-meaningful (`<name>.Invoked`) for bus subscribers;
|
|
612
|
+
* everything a debug consumer needs to rebuild the call tree rides here, so the
|
|
613
|
+
* consumer never parses the dotted name. `spanId`/`parentSpanId` are the
|
|
614
|
+
* tracer's `invocationId`/`parentInvocationId` (present only while tracing);
|
|
615
|
+
* `ref` carries the kind+name the name no longer encodes; `detail` is the
|
|
616
|
+
* per-capability data (inputs/outputs, error fields, cancellation reason).
|
|
617
|
+
*/
|
|
618
|
+
/**
|
|
619
|
+
* A redacted snapshot of the CEL root scope a debug consumer should see for a
|
|
620
|
+
* trace — `variables`, masked `secrets`, resource `snapshots`, `ports`. Attached
|
|
621
|
+
* to a trace's *root* span so the consumer can inspect what data the execution
|
|
622
|
+
* could reference (beyond its own inputs/outputs). Only a `ModuleContext` owns a
|
|
623
|
+
* root scope; child scopes return undefined. Host `env` is deliberately omitted
|
|
624
|
+
* (it is the raw process environment — too broad/sensitive to dump).
|
|
625
|
+
*/
|
|
626
|
+
protected traceRootScope(): Record<string, unknown> | undefined {
|
|
627
|
+
return undefined;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
protected tracePayload(
|
|
631
|
+
kind: string,
|
|
632
|
+
name: string,
|
|
633
|
+
spanId: number | undefined,
|
|
634
|
+
parentSpanId: number | undefined,
|
|
635
|
+
traceId: string | undefined,
|
|
636
|
+
capability: "invoke" | "run" | "provide" | "request",
|
|
637
|
+
phase: "start" | "end",
|
|
638
|
+
outcome: "ok" | "failed" | "rejected" | "cancelled" | undefined,
|
|
639
|
+
detail: Record<string, unknown>,
|
|
640
|
+
): Record<string, unknown> {
|
|
641
|
+
return {
|
|
642
|
+
traceId,
|
|
643
|
+
spanId,
|
|
644
|
+
parentSpanId,
|
|
645
|
+
capability,
|
|
646
|
+
phase,
|
|
647
|
+
...(outcome !== undefined ? { outcome } : {}),
|
|
648
|
+
ref: { kind, name },
|
|
649
|
+
...detail,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
597
653
|
private async runInvoke<TInputs>(
|
|
598
654
|
kind: string,
|
|
599
655
|
name: string,
|
|
@@ -605,19 +661,64 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
605
661
|
// tree token; otherwise open a fresh, never-cancellable scope. The token
|
|
606
662
|
// reaches the controller only as the explicit argument below.
|
|
607
663
|
const ambient = cancellationStore.getStore();
|
|
608
|
-
const
|
|
609
|
-
const token =
|
|
664
|
+
const baseCtx = ctx ?? ambient ?? UNCANCELLABLE_CONTEXT;
|
|
665
|
+
const token = baseCtx.cancellation;
|
|
666
|
+
|
|
667
|
+
// Tracing gate (a debug consumer is attached): mint a monotonic id for this
|
|
668
|
+
// invocation, parent it to the ambient one, and ride both in every event's
|
|
669
|
+
// payload so the consumer can rebuild the call tree. Off by default —
|
|
670
|
+
// `invokeCtx` stays `baseCtx` and the fast `=== ambient` skip is preserved.
|
|
671
|
+
const tracing = this.tracer?.enabled === true;
|
|
672
|
+
const invocationId = tracing ? this.tracer!.next() : undefined;
|
|
673
|
+
// Parent precedence mirrors the token: an explicit seed `ctx` wins, then the
|
|
674
|
+
// ambient (ALS) invocation. A caller threading its own `ctx.invocationId` thus
|
|
675
|
+
// has it honored as the parent, not silently dropped for the ALS value.
|
|
676
|
+
const parentInvocationId = ctx?.invocationId ?? ambient?.invocationId;
|
|
677
|
+
// Inherit the trace from the parent (explicit ctx wins, then ambient); mint a
|
|
678
|
+
// fresh one only at a root. Carried on every span so an OTel exporter groups
|
|
679
|
+
// the trace without walking the parent chain.
|
|
680
|
+
const traceId = tracing
|
|
681
|
+
? (ctx?.traceId ?? ambient?.traceId ?? this.tracer!.newTraceId())
|
|
682
|
+
: undefined;
|
|
683
|
+
// Capture the root CEL scope once, on the trace's root span's terminal event.
|
|
684
|
+
const rootScope = tracing && parentInvocationId === undefined ? this.traceRootScope() : undefined;
|
|
685
|
+
const span = (
|
|
686
|
+
phase: "start" | "end",
|
|
687
|
+
outcome: "ok" | "failed" | "rejected" | "cancelled" | undefined,
|
|
688
|
+
detail: Record<string, unknown>,
|
|
689
|
+
) =>
|
|
690
|
+
this.tracePayload(
|
|
691
|
+
kind,
|
|
692
|
+
name,
|
|
693
|
+
invocationId,
|
|
694
|
+
parentInvocationId,
|
|
695
|
+
traceId,
|
|
696
|
+
"invoke",
|
|
697
|
+
phase,
|
|
698
|
+
outcome,
|
|
699
|
+
phase === "end" && rootScope ? { ...detail, context: rootScope } : detail,
|
|
700
|
+
);
|
|
701
|
+
// When tracing, a fresh context carries the new id down the tree so nested
|
|
702
|
+
// invokes read it as their parent; it is never `=== ambient`, so the call
|
|
703
|
+
// always (re)establishes the ALS scope.
|
|
704
|
+
const invokeCtx: InvokeContext = tracing
|
|
705
|
+
? { cancellation: token, invocationId, parentInvocationId, traceId }
|
|
706
|
+
: baseCtx;
|
|
610
707
|
|
|
611
708
|
// Pre-dispatch gate: a sub-invoke reached after the tree was cancelled is
|
|
612
709
|
// refused without ever touching the controller.
|
|
613
710
|
if (token.isCancelled) {
|
|
614
|
-
await this.emit(`${
|
|
711
|
+
await this.emit(`${name}.InvokeCancelled`, span("end", "cancelled", { inputs, reason: token.reason }));
|
|
615
712
|
throw new RuntimeError(
|
|
616
713
|
"ERR_INVOKE_CANCELLED",
|
|
617
714
|
`Invoke ${kind}.${name} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
|
|
618
715
|
);
|
|
619
716
|
}
|
|
620
717
|
|
|
718
|
+
// Start span — only under tracing, so non-traced behaviour stays exactly
|
|
719
|
+
// one terminal event per call (subscribers to `<name>.Invoked` are unaffected).
|
|
720
|
+
if (tracing) await this.emit(`${name}.Invoking`, span("start", undefined, { inputs }));
|
|
721
|
+
|
|
621
722
|
try {
|
|
622
723
|
// Only (re)establish the ALS scope when the token differs from the ambient
|
|
623
724
|
// one — nested invokes that inherited it skip the redundant `run`.
|
|
@@ -633,35 +734,35 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
633
734
|
const outputs = await (invokeCtx === ambient
|
|
634
735
|
? call()
|
|
635
736
|
: cancellationStore.run(invokeCtx, call));
|
|
636
|
-
await this.emit(`${
|
|
737
|
+
await this.emit(`${name}.Invoked`, span("end", "ok", { inputs, outputs }));
|
|
637
738
|
return outputs;
|
|
638
739
|
} catch (err) {
|
|
639
740
|
// Cooperative mid-flight cancellation (`throwIfCancelled`) joins the same
|
|
640
741
|
// observable event family rather than masquerading as a rejection/failure.
|
|
641
742
|
if (isCancellationError(err)) {
|
|
642
743
|
const reason = err instanceof Error ? err.message : String(err);
|
|
643
|
-
await this.emit(`${
|
|
744
|
+
await this.emit(`${name}.InvokeCancelled`, span("end", "cancelled", { inputs, reason }));
|
|
644
745
|
throw err;
|
|
645
746
|
}
|
|
646
747
|
if (isInvokeError(err)) {
|
|
647
|
-
const
|
|
648
|
-
await this.emit(`${
|
|
748
|
+
const detail = { inputs, code: err.code, message: err.message, data: err.data };
|
|
749
|
+
await this.emit(`${name}.InvokeRejected`, span("end", "rejected", detail));
|
|
649
750
|
const declaredCodes = this.getDeclaredThrowCodes(kind);
|
|
650
751
|
if (declaredCodes && !declaredCodes.has(err.code)) {
|
|
651
|
-
await this.emit(`${
|
|
752
|
+
await this.emit(`${name}.InvokeRejected.Undeclared`, span("end", "rejected", detail));
|
|
652
753
|
}
|
|
653
754
|
throw err;
|
|
654
755
|
}
|
|
655
756
|
if (err instanceof Error) {
|
|
656
|
-
await this.emit(
|
|
657
|
-
name
|
|
658
|
-
message: err.message,
|
|
659
|
-
|
|
757
|
+
await this.emit(
|
|
758
|
+
`${name}.InvokeFailed`,
|
|
759
|
+
span("end", "failed", { inputs, name: err.name, message: err.message }),
|
|
760
|
+
);
|
|
660
761
|
} else {
|
|
661
|
-
await this.emit(
|
|
662
|
-
name
|
|
663
|
-
message: String(err),
|
|
664
|
-
|
|
762
|
+
await this.emit(
|
|
763
|
+
`${name}.InvokeFailed`,
|
|
764
|
+
span("end", "failed", { inputs, name: "UnknownError", message: String(err) }),
|
|
765
|
+
);
|
|
665
766
|
}
|
|
666
767
|
// Already enriched at an inner invoke: keep the innermost (most
|
|
667
768
|
// specific) resource as the failure location.
|
|
@@ -682,6 +783,26 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
682
783
|
}
|
|
683
784
|
}
|
|
684
785
|
|
|
786
|
+
/**
|
|
787
|
+
* The declared capability of a kind, or undefined if unknown. Definitions are
|
|
788
|
+
* keyed by their canonical `<module>.<Kind>`, but a resource carries the alias
|
|
789
|
+
* kind it was written with (`Http.Server`), so resolve the alias first.
|
|
790
|
+
* Best-effort: `resolveKind` exists only on a `ModuleContext` and throws for
|
|
791
|
+
* unqualified / ungated kinds, so guard and fall back to the raw kind.
|
|
792
|
+
*/
|
|
793
|
+
/** Resolve an alias kind (`Http.Server`) to its canonical `<module>.<Kind>`.
|
|
794
|
+
* Only a `ModuleContext` carries the import-alias table; the base returns the
|
|
795
|
+
* kind unchanged. A typed seam (overridden in `ModuleContext`), matching the
|
|
796
|
+
* `traceRootScope()` pattern, rather than reaching across the boundary. */
|
|
797
|
+
protected resolveKindSafe(kind: string): string {
|
|
798
|
+
return kind;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private capabilityOf(kind: string): string | undefined {
|
|
802
|
+
const resolved = this.resolveKindSafe(kind);
|
|
803
|
+
return this.getDefinition?.(resolved)?.capability ?? this.getDefinition?.(kind)?.capability;
|
|
804
|
+
}
|
|
805
|
+
|
|
685
806
|
private getDeclaredThrowCodes(kind: string): Set<string> | null {
|
|
686
807
|
if (!this.getDefinition) return null;
|
|
687
808
|
const def = this.getDefinition(kind);
|
|
@@ -700,28 +821,178 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
700
821
|
|
|
701
822
|
async run(name: string, ctx?: InvokeContext): Promise<void> {
|
|
702
823
|
const entry = this.resourceInstances.get(name);
|
|
703
|
-
if (entry && typeof entry.instance.run === "function") {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
824
|
+
if (!(entry && typeof entry.instance.run === "function")) {
|
|
825
|
+
throw new RuntimeError(
|
|
826
|
+
"ERR_RESOURCE_NOT_RUNNABLE",
|
|
827
|
+
`Resource ${name} is not runnable or not found. Available resources: ${[...this.resourceInstances.keys()].join(", ")}`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
return this.runInstance(entry.resource.kind as string, name, entry.instance, ctx);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Like run(), but the caller has already resolved the instance (e.g. a
|
|
835
|
+
* Phase-5-injected `!ref` boot target). Shares the single span-emitting path so
|
|
836
|
+
* a pre-resolved runnable is instrumented exactly like a by-name dispatch
|
|
837
|
+
* instead of escaping the chokepoint with a direct `instance.run()` call.
|
|
838
|
+
*/
|
|
839
|
+
async runResolved(
|
|
840
|
+
kind: string,
|
|
841
|
+
name: string,
|
|
842
|
+
instance: ResourceInstance,
|
|
843
|
+
ctx?: InvokeContext,
|
|
844
|
+
): Promise<void> {
|
|
845
|
+
if (typeof instance.run !== "function") {
|
|
846
|
+
throw new RuntimeError(
|
|
847
|
+
"ERR_RESOURCE_NOT_RUNNABLE",
|
|
848
|
+
`Resource ${kind}.${name} does not have a run method`,
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
return this.runInstance(kind, name, instance, ctx);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Open a trace span for an inbound boundary (an HTTP request). Mints a span
|
|
856
|
+
* that roots a fresh trace (or continues `opts.inbound`), emits its `start`,
|
|
857
|
+
* and returns a child context to thread into `invokeResolved` so the handler
|
|
858
|
+
* nests under it. A no-op pass-through when tracing is off.
|
|
859
|
+
*/
|
|
860
|
+
async openSpan(base: InvokeContext | undefined, opts: OpenSpanOptions): Promise<OpenSpan> {
|
|
861
|
+
const ctx = base ?? UNCANCELLABLE_CONTEXT;
|
|
862
|
+
if (this.tracer?.enabled !== true) {
|
|
863
|
+
return { context: ctx, settle: async () => {} };
|
|
864
|
+
}
|
|
865
|
+
const spanId = this.tracer.next();
|
|
866
|
+
const traceId = opts.inbound?.traceId ?? this.tracer.newTraceId();
|
|
867
|
+
const parentSpanId = opts.inbound?.parentSpanId;
|
|
868
|
+
// A root request span (not continuing an upstream trace) carries the root scope.
|
|
869
|
+
const rootScope = parentSpanId === undefined ? this.traceRootScope() : undefined;
|
|
870
|
+
const detail = {
|
|
871
|
+
...(opts.label !== undefined ? { label: opts.label } : {}),
|
|
872
|
+
...(opts.attributes !== undefined ? { attributes: opts.attributes } : {}),
|
|
873
|
+
};
|
|
874
|
+
const payload = (
|
|
875
|
+
phase: "start" | "end",
|
|
876
|
+
outcome: "ok" | "failed" | "rejected" | "cancelled" | undefined,
|
|
877
|
+
extra: Record<string, unknown> = {},
|
|
878
|
+
) =>
|
|
879
|
+
this.tracePayload(
|
|
880
|
+
opts.ref.kind,
|
|
881
|
+
opts.ref.name,
|
|
882
|
+
spanId,
|
|
883
|
+
parentSpanId,
|
|
884
|
+
traceId,
|
|
885
|
+
"request",
|
|
886
|
+
phase,
|
|
887
|
+
outcome,
|
|
888
|
+
{ ...detail, ...extra },
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
await this.emit(`${opts.ref.name}.Requesting`, payload("start", undefined));
|
|
892
|
+
const context: InvokeContext = {
|
|
893
|
+
cancellation: ctx.cancellation,
|
|
894
|
+
invocationId: spanId,
|
|
895
|
+
parentInvocationId: parentSpanId,
|
|
896
|
+
traceId,
|
|
897
|
+
};
|
|
898
|
+
let settled = false;
|
|
899
|
+
return {
|
|
900
|
+
context,
|
|
901
|
+
settle: async (outcome, extra) => {
|
|
902
|
+
if (settled) return;
|
|
903
|
+
settled = true;
|
|
904
|
+
await this.emit(
|
|
905
|
+
`${opts.ref.name}.Request`,
|
|
906
|
+
payload("end", outcome, rootScope ? { ...extra, context: rootScope } : extra),
|
|
713
907
|
);
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private async runInstance(
|
|
913
|
+
kind: string,
|
|
914
|
+
name: string,
|
|
915
|
+
instance: ResourceInstance,
|
|
916
|
+
ctx?: InvokeContext,
|
|
917
|
+
): Promise<void> {
|
|
918
|
+
const ambient = cancellationStore.getStore();
|
|
919
|
+
const baseCtx = ctx ?? ambient ?? UNCANCELLABLE_CONTEXT;
|
|
920
|
+
const token = baseCtx.cancellation;
|
|
921
|
+
|
|
922
|
+
// A long-lived Service's `run()` is not a one-shot dispatch: it stays pending
|
|
923
|
+
// for the process lifetime, so wrapping it in the cancellation/trace ALS scope
|
|
924
|
+
// would leak that scope onto every async resource the service creates (e.g. an
|
|
925
|
+
// HTTP server's listening socket → every inbound request callback). Such a
|
|
926
|
+
// service must NOT establish an ambient: its token reaches it via the explicit
|
|
927
|
+
// `run(invokeCtx)` argument (how it observes shutdown), and its externally
|
|
928
|
+
// triggered work then starts with a clean ambient — separate traces, no
|
|
929
|
+
// inherited cancellation. Runnables (one-shot, e.g. `Run.Sequence`) keep the
|
|
930
|
+
// ALS scope so their steps nest and inherit cancellation.
|
|
931
|
+
const isService = this.capabilityOf(kind) === "Telo.Service";
|
|
932
|
+
|
|
933
|
+
// Span instrumentation mirrors `runInvoke` — minting an id here makes
|
|
934
|
+
// Runnables (a `Run.Sequence` boot target) appear in the trace and re-parents
|
|
935
|
+
// their nested invokes. A long-lived Service emits only the `start` span (its
|
|
936
|
+
// `run()` resolves at teardown), the "running" signal a debug consumer wants.
|
|
937
|
+
const tracing = this.tracer?.enabled === true;
|
|
938
|
+
const invocationId = tracing ? this.tracer!.next() : undefined;
|
|
939
|
+
const parentInvocationId = ctx?.invocationId ?? ambient?.invocationId;
|
|
940
|
+
const traceId = tracing
|
|
941
|
+
? (ctx?.traceId ?? ambient?.traceId ?? this.tracer!.newTraceId())
|
|
942
|
+
: undefined;
|
|
943
|
+
const rootScope = tracing && parentInvocationId === undefined ? this.traceRootScope() : undefined;
|
|
944
|
+
const span = (
|
|
945
|
+
phase: "start" | "end",
|
|
946
|
+
outcome: "ok" | "failed" | "cancelled" | undefined,
|
|
947
|
+
detail: Record<string, unknown>,
|
|
948
|
+
) =>
|
|
949
|
+
this.tracePayload(
|
|
950
|
+
kind,
|
|
951
|
+
name,
|
|
952
|
+
invocationId,
|
|
953
|
+
parentInvocationId,
|
|
954
|
+
traceId,
|
|
955
|
+
"run",
|
|
956
|
+
phase,
|
|
957
|
+
outcome,
|
|
958
|
+
phase === "end" && rootScope ? { ...detail, context: rootScope } : detail,
|
|
959
|
+
);
|
|
960
|
+
const invokeCtx: InvokeContext = tracing
|
|
961
|
+
? { cancellation: token, invocationId, parentInvocationId, traceId }
|
|
962
|
+
: baseCtx;
|
|
963
|
+
|
|
964
|
+
// Refuse a target reached after the boot run was cancelled.
|
|
965
|
+
if (token.isCancelled) {
|
|
966
|
+
await this.emit(`${name}.RunCancelled`, span("end", "cancelled", { reason: token.reason }));
|
|
967
|
+
throw new RuntimeError(
|
|
968
|
+
"ERR_INVOKE_CANCELLED",
|
|
969
|
+
`Run ${kind}.${name} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (tracing) await this.emit(`${name}.Running`, span("start", undefined, {}));
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
// Runnable: run inside the ALS scope so nested invokes inherit the token and
|
|
977
|
+
// trace id (skip the redundant `run` when the token is already ambient).
|
|
978
|
+
// Service: call directly with the explicit context and NO ambient scope, so
|
|
979
|
+
// its long-lived async work does not capture this scope.
|
|
980
|
+
const call = () => (instance.run as (c?: InvokeContext) => Promise<void>)(invokeCtx);
|
|
981
|
+
await (isService || invokeCtx === ambient ? call() : cancellationStore.run(invokeCtx, call));
|
|
982
|
+
await this.emit(`${name}.Run`, span("end", "ok", {}));
|
|
983
|
+
} catch (err) {
|
|
984
|
+
if (isCancellationError(err)) {
|
|
985
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
986
|
+
await this.emit(`${name}.RunCancelled`, span("end", "cancelled", { reason }));
|
|
987
|
+
throw err;
|
|
714
988
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
989
|
+
const detail =
|
|
990
|
+
err instanceof Error
|
|
991
|
+
? { name: err.name, message: err.message }
|
|
992
|
+
: { name: "UnknownError", message: String(err) };
|
|
993
|
+
await this.emit(`${name}.RunFailed`, span("end", "failed", detail));
|
|
994
|
+
throw err;
|
|
720
995
|
}
|
|
721
|
-
throw new RuntimeError(
|
|
722
|
-
"ERR_RESOURCE_NOT_RUNNABLE",
|
|
723
|
-
`Resource ${name} is not runnable or not found. Available resources: ${[...this.resourceInstances.keys()].join(", ")}`,
|
|
724
|
-
);
|
|
725
996
|
}
|
|
726
997
|
|
|
727
998
|
/**
|
package/src/events.ts
CHANGED
|
@@ -70,6 +70,11 @@ export class EventBus {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
async emit(event: string, payload?: any, metadata?: any): Promise<void> {
|
|
73
|
+
// O(1) idle short-circuit: with no subscriber at all — the common case when
|
|
74
|
+
// no debug consumer is attached — emitting costs a single integer compare,
|
|
75
|
+
// no map walk and no allocation. This is what keeps routing every dispatch
|
|
76
|
+
// through the instrumented chokepoint effectively free when nobody listens.
|
|
77
|
+
if (this.handlers.size === 0) return;
|
|
73
78
|
const handlers: EventHandler[] = [];
|
|
74
79
|
for (const [pattern, set] of this.handlers.entries()) {
|
|
75
80
|
if (!this.matchesPattern(pattern, event)) {
|
package/src/kernel.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import { parseArgs } from "util";
|
|
31
31
|
import { ControllerRegistry } from "./controller-registry.js";
|
|
32
32
|
import { EventBus } from "./events.js";
|
|
33
|
+
import { KernelTracer } from "./tracing.js";
|
|
33
34
|
import { ModuleContext } from "./module-context.js";
|
|
34
35
|
import { ResourceContextImpl } from "./resource-context.js";
|
|
35
36
|
import { nodeCelHandlers } from "./cel-handlers.js";
|
|
@@ -104,6 +105,7 @@ export class Kernel implements IKernel {
|
|
|
104
105
|
private readonly registry = new AnalysisRegistry();
|
|
105
106
|
private controllers: ControllerRegistry = new ControllerRegistry();
|
|
106
107
|
private eventBus: EventBus = new EventBus();
|
|
108
|
+
private readonly tracer = new KernelTracer();
|
|
107
109
|
|
|
108
110
|
private holdCount = 0;
|
|
109
111
|
private idleResolvers: Array<() => void> = [];
|
|
@@ -130,6 +132,9 @@ export class Kernel implements IKernel {
|
|
|
130
132
|
// SIGINT (before runTargets) still has a source to cancel, which the run then
|
|
131
133
|
// observes via the pre-dispatch gate.
|
|
132
134
|
private _bootCancellation?: CancellationSource;
|
|
135
|
+
// Root application name — labels the boot `targets` trace span so the app
|
|
136
|
+
// appears as the trace root with its targets nested beneath.
|
|
137
|
+
private _appName?: string;
|
|
133
138
|
|
|
134
139
|
readonly stdin: NodeJS.ReadableStream;
|
|
135
140
|
readonly stdout: NodeJS.WritableStream;
|
|
@@ -317,9 +322,10 @@ export class Kernel implements IKernel {
|
|
|
317
322
|
{},
|
|
318
323
|
[],
|
|
319
324
|
this._createInstance.bind(this),
|
|
320
|
-
(event, payload) => this.eventBus.emit(event, payload),
|
|
325
|
+
(event, payload, metadata) => this.eventBus.emit(event, payload, metadata),
|
|
321
326
|
this.env,
|
|
322
327
|
);
|
|
328
|
+
this.rootContext.tracer = this.tracer;
|
|
323
329
|
// Initialize built-in Runtime definitions first
|
|
324
330
|
await this.loadBuiltinDefinitions();
|
|
325
331
|
|
|
@@ -508,6 +514,7 @@ export class Kernel implements IKernel {
|
|
|
508
514
|
this.rootContext.setTargets(rawTargets as BootTarget[]);
|
|
509
515
|
if (manifest.kind === "Telo.Application") {
|
|
510
516
|
rootApplicationManifest = manifest;
|
|
517
|
+
this._appName = (manifest.metadata as { name?: string } | undefined)?.name;
|
|
511
518
|
}
|
|
512
519
|
}
|
|
513
520
|
this.rootContext.registerManifest(manifest);
|
|
@@ -635,7 +642,7 @@ export class Kernel implements IKernel {
|
|
|
635
642
|
this._targetsRan = true;
|
|
636
643
|
|
|
637
644
|
await this.eventBus.emit("Kernel.Starting", {});
|
|
638
|
-
await this.rootContext.runTargets(this.bootCancellation.context);
|
|
645
|
+
await this.rootContext.runTargets(this.bootCancellation.context, this._appName);
|
|
639
646
|
await this.eventBus.emit("Kernel.Started", {});
|
|
640
647
|
}
|
|
641
648
|
|
|
@@ -817,6 +824,16 @@ export class Kernel implements IKernel {
|
|
|
817
824
|
return this.eventBus.hasHandlers(event);
|
|
818
825
|
}
|
|
819
826
|
|
|
827
|
+
/**
|
|
828
|
+
* Turn invocation tracing on/off. A debug consumer (the CLI debug server) flips
|
|
829
|
+
* it on while attached: invocations then mint monotonic ids and emit
|
|
830
|
+
* `invocationId` / `parentInvocationId` in event metadata, so the consumer can
|
|
831
|
+
* rebuild the call tree. Off by default — zero overhead when nobody is watching.
|
|
832
|
+
*/
|
|
833
|
+
setTracing(enabled: boolean): void {
|
|
834
|
+
this.tracer.enabled = enabled;
|
|
835
|
+
}
|
|
836
|
+
|
|
820
837
|
on(event: string, handler: (event: RuntimeEvent) => void | Promise<void>): void {
|
|
821
838
|
this.eventBus.on(event, handler);
|
|
822
839
|
}
|