@telorun/kernel 0.31.0 → 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 +48 -1
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +186 -27
- 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 +1 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +2 -1
- 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 +3 -0
- package/dist/tracing.d.ts.map +1 -1
- package/dist/tracing.js +6 -0
- package/dist/tracing.js.map +1 -1
- package/package.json +2 -2
- package/src/dependency-injection.ts +7 -1
- package/src/evaluation-context.ts +277 -36
- package/src/events.ts +5 -0
- package/src/kernel.ts +5 -1
- package/src/module-context.ts +100 -17
- package/src/resource-context.ts +7 -1
- package/src/tracing.ts +7 -0
|
@@ -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,
|
|
@@ -604,6 +606,50 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
604
606
|
return this.runInvoke(kind, name, instance, inputs, ctx);
|
|
605
607
|
}
|
|
606
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
|
+
|
|
607
653
|
private async runInvoke<TInputs>(
|
|
608
654
|
kind: string,
|
|
609
655
|
name: string,
|
|
@@ -619,33 +665,60 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
619
665
|
const token = baseCtx.cancellation;
|
|
620
666
|
|
|
621
667
|
// Tracing gate (a debug consumer is attached): mint a monotonic id for this
|
|
622
|
-
// invocation, parent it to the ambient one, and ride both
|
|
623
|
-
//
|
|
624
|
-
// `
|
|
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.
|
|
625
671
|
const tracing = this.tracer?.enabled === true;
|
|
626
672
|
const invocationId = tracing ? this.tracer!.next() : undefined;
|
|
627
673
|
// Parent precedence mirrors the token: an explicit seed `ctx` wins, then the
|
|
628
674
|
// ambient (ALS) invocation. A caller threading its own `ctx.invocationId` thus
|
|
629
675
|
// has it honored as the parent, not silently dropped for the ALS value.
|
|
630
676
|
const parentInvocationId = ctx?.invocationId ?? ambient?.invocationId;
|
|
631
|
-
|
|
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
|
+
);
|
|
632
701
|
// When tracing, a fresh context carries the new id down the tree so nested
|
|
633
702
|
// invokes read it as their parent; it is never `=== ambient`, so the call
|
|
634
703
|
// always (re)establishes the ALS scope.
|
|
635
704
|
const invokeCtx: InvokeContext = tracing
|
|
636
|
-
? { cancellation: token, invocationId, parentInvocationId }
|
|
705
|
+
? { cancellation: token, invocationId, parentInvocationId, traceId }
|
|
637
706
|
: baseCtx;
|
|
638
707
|
|
|
639
708
|
// Pre-dispatch gate: a sub-invoke reached after the tree was cancelled is
|
|
640
709
|
// refused without ever touching the controller.
|
|
641
710
|
if (token.isCancelled) {
|
|
642
|
-
await this.emit(`${
|
|
711
|
+
await this.emit(`${name}.InvokeCancelled`, span("end", "cancelled", { inputs, reason: token.reason }));
|
|
643
712
|
throw new RuntimeError(
|
|
644
713
|
"ERR_INVOKE_CANCELLED",
|
|
645
714
|
`Invoke ${kind}.${name} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
|
|
646
715
|
);
|
|
647
716
|
}
|
|
648
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
|
+
|
|
649
722
|
try {
|
|
650
723
|
// Only (re)establish the ALS scope when the token differs from the ambient
|
|
651
724
|
// one — nested invokes that inherited it skip the redundant `run`.
|
|
@@ -661,36 +734,34 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
661
734
|
const outputs = await (invokeCtx === ambient
|
|
662
735
|
? call()
|
|
663
736
|
: cancellationStore.run(invokeCtx, call));
|
|
664
|
-
await this.emit(`${
|
|
737
|
+
await this.emit(`${name}.Invoked`, span("end", "ok", { inputs, outputs }));
|
|
665
738
|
return outputs;
|
|
666
739
|
} catch (err) {
|
|
667
740
|
// Cooperative mid-flight cancellation (`throwIfCancelled`) joins the same
|
|
668
741
|
// observable event family rather than masquerading as a rejection/failure.
|
|
669
742
|
if (isCancellationError(err)) {
|
|
670
743
|
const reason = err instanceof Error ? err.message : String(err);
|
|
671
|
-
await this.emit(`${
|
|
744
|
+
await this.emit(`${name}.InvokeCancelled`, span("end", "cancelled", { inputs, reason }));
|
|
672
745
|
throw err;
|
|
673
746
|
}
|
|
674
747
|
if (isInvokeError(err)) {
|
|
675
|
-
const
|
|
676
|
-
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));
|
|
677
750
|
const declaredCodes = this.getDeclaredThrowCodes(kind);
|
|
678
751
|
if (declaredCodes && !declaredCodes.has(err.code)) {
|
|
679
|
-
await this.emit(`${
|
|
752
|
+
await this.emit(`${name}.InvokeRejected.Undeclared`, span("end", "rejected", detail));
|
|
680
753
|
}
|
|
681
754
|
throw err;
|
|
682
755
|
}
|
|
683
756
|
if (err instanceof Error) {
|
|
684
757
|
await this.emit(
|
|
685
|
-
`${
|
|
686
|
-
{ inputs, name: err.name, message: err.message },
|
|
687
|
-
meta,
|
|
758
|
+
`${name}.InvokeFailed`,
|
|
759
|
+
span("end", "failed", { inputs, name: err.name, message: err.message }),
|
|
688
760
|
);
|
|
689
761
|
} else {
|
|
690
762
|
await this.emit(
|
|
691
|
-
`${
|
|
692
|
-
{ inputs, name: "UnknownError", message: String(err) },
|
|
693
|
-
meta,
|
|
763
|
+
`${name}.InvokeFailed`,
|
|
764
|
+
span("end", "failed", { inputs, name: "UnknownError", message: String(err) }),
|
|
694
765
|
);
|
|
695
766
|
}
|
|
696
767
|
// Already enriched at an inner invoke: keep the innermost (most
|
|
@@ -712,6 +783,26 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
712
783
|
}
|
|
713
784
|
}
|
|
714
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
|
+
|
|
715
806
|
private getDeclaredThrowCodes(kind: string): Set<string> | null {
|
|
716
807
|
if (!this.getDefinition) return null;
|
|
717
808
|
const def = this.getDefinition(kind);
|
|
@@ -730,28 +821,178 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
730
821
|
|
|
731
822
|
async run(name: string, ctx?: InvokeContext): Promise<void> {
|
|
732
823
|
const entry = this.resourceInstances.get(name);
|
|
733
|
-
if (entry && typeof entry.instance.run === "function") {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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),
|
|
743
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;
|
|
744
988
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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;
|
|
750
995
|
}
|
|
751
|
-
throw new RuntimeError(
|
|
752
|
-
"ERR_RESOURCE_NOT_RUNNABLE",
|
|
753
|
-
`Resource ${name} is not runnable or not found. Available resources: ${[...this.resourceInstances.keys()].join(", ")}`,
|
|
754
|
-
);
|
|
755
996
|
}
|
|
756
997
|
|
|
757
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
|
@@ -132,6 +132,9 @@ export class Kernel implements IKernel {
|
|
|
132
132
|
// SIGINT (before runTargets) still has a source to cancel, which the run then
|
|
133
133
|
// observes via the pre-dispatch gate.
|
|
134
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;
|
|
135
138
|
|
|
136
139
|
readonly stdin: NodeJS.ReadableStream;
|
|
137
140
|
readonly stdout: NodeJS.WritableStream;
|
|
@@ -511,6 +514,7 @@ export class Kernel implements IKernel {
|
|
|
511
514
|
this.rootContext.setTargets(rawTargets as BootTarget[]);
|
|
512
515
|
if (manifest.kind === "Telo.Application") {
|
|
513
516
|
rootApplicationManifest = manifest;
|
|
517
|
+
this._appName = (manifest.metadata as { name?: string } | undefined)?.name;
|
|
514
518
|
}
|
|
515
519
|
}
|
|
516
520
|
this.rootContext.registerManifest(manifest);
|
|
@@ -638,7 +642,7 @@ export class Kernel implements IKernel {
|
|
|
638
642
|
this._targetsRan = true;
|
|
639
643
|
|
|
640
644
|
await this.eventBus.emit("Kernel.Starting", {});
|
|
641
|
-
await this.rootContext.runTargets(this.bootCancellation.context);
|
|
645
|
+
await this.rootContext.runTargets(this.bootCancellation.context, this._appName);
|
|
642
646
|
await this.eventBus.emit("Kernel.Started", {});
|
|
643
647
|
}
|
|
644
648
|
|
package/src/module-context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { executeInvokeStep, RuntimeError } from "@telorun/sdk";
|
|
1
|
+
import { executeInvokeStep, getRefIdentity, RuntimeError } from "@telorun/sdk";
|
|
2
2
|
import type {
|
|
3
3
|
BootTarget,
|
|
4
4
|
ControllerPolicy,
|
|
@@ -432,6 +432,29 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
432
432
|
return `${realModule}.${suffix}`;
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
+
protected override resolveKindSafe(kind: string): string {
|
|
436
|
+
// `resolveKind` throws for unqualified / ungated kinds — an expected signal,
|
|
437
|
+
// not a failure: a capability probe that can't resolve falls back to the raw
|
|
438
|
+
// kind (lookup misses → treated as non-service → keeps its ALS scope).
|
|
439
|
+
try {
|
|
440
|
+
return this.resolveKind(kind);
|
|
441
|
+
} catch {
|
|
442
|
+
return kind;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
protected override traceRootScope(): Record<string, unknown> {
|
|
447
|
+
// Mask secret values (keep keys so availability is visible, never the value).
|
|
448
|
+
const secrets: Record<string, unknown> = {};
|
|
449
|
+
for (const key of Object.keys(this._secrets)) secrets[key] = "[secret]";
|
|
450
|
+
return {
|
|
451
|
+
variables: this._variables,
|
|
452
|
+
secrets,
|
|
453
|
+
resources: this._resources,
|
|
454
|
+
ports: this._ports,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
435
458
|
private _rebuildContext(): void {
|
|
436
459
|
this._context = {
|
|
437
460
|
variables: this._variables,
|
|
@@ -473,26 +496,84 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
473
496
|
await super.run(name, ctx);
|
|
474
497
|
}
|
|
475
498
|
|
|
476
|
-
async runTargets(ctx?: InvokeContext) {
|
|
499
|
+
async runTargets(ctx?: InvokeContext, appName?: string) {
|
|
500
|
+
// Open a trace span for the application's boot run so the app itself is a
|
|
501
|
+
// trace participant: every target dispatches under `targetCtx`, parenting to
|
|
502
|
+
// this span instead of becoming a detached root. The span is gated on tracing
|
|
503
|
+
// (zero cost otherwise) and rides the boot cancellation token unchanged.
|
|
504
|
+
const tracing = this.tracer?.enabled === true;
|
|
505
|
+
const appSpanId = tracing ? this.tracer!.next() : undefined;
|
|
506
|
+
// The boot run roots a fresh trace; targets inherit it via `targetCtx`.
|
|
507
|
+
const appTraceId = tracing ? this.tracer!.newTraceId() : undefined;
|
|
508
|
+
const appRootScope = tracing ? this.traceRootScope() : undefined;
|
|
509
|
+
const appLabel = appName ?? "application";
|
|
510
|
+
const appSpan = (
|
|
511
|
+
phase: "start" | "end",
|
|
512
|
+
outcome: "ok" | "failed" | "cancelled" | undefined,
|
|
513
|
+
) =>
|
|
514
|
+
this.tracePayload(
|
|
515
|
+
"Telo.Application",
|
|
516
|
+
appLabel,
|
|
517
|
+
appSpanId,
|
|
518
|
+
undefined,
|
|
519
|
+
appTraceId,
|
|
520
|
+
"run",
|
|
521
|
+
phase,
|
|
522
|
+
outcome,
|
|
523
|
+
phase === "end" && appRootScope ? { context: appRootScope } : {},
|
|
524
|
+
);
|
|
525
|
+
const targetCtx: InvokeContext | undefined =
|
|
526
|
+
tracing && ctx
|
|
527
|
+
? {
|
|
528
|
+
cancellation: ctx.cancellation,
|
|
529
|
+
invocationId: appSpanId,
|
|
530
|
+
parentInvocationId: undefined,
|
|
531
|
+
traceId: appTraceId,
|
|
532
|
+
}
|
|
533
|
+
: ctx;
|
|
534
|
+
|
|
477
535
|
const steps: Record<string, unknown> = {};
|
|
478
536
|
const stepCtx: InvokeStepContext = {
|
|
479
537
|
expandValue: (value, context) => this.expandWith(value, context),
|
|
480
|
-
invoke: (kind, name, inputs) => this.invoke(kind, name, inputs,
|
|
538
|
+
invoke: (kind, name, inputs) => this.invoke(kind, name, inputs, targetCtx),
|
|
481
539
|
invokeResolved: (kind, name, instance, inputs) =>
|
|
482
|
-
this.invokeResolved(kind, name, instance, inputs,
|
|
540
|
+
this.invokeResolved(kind, name, instance, inputs, targetCtx),
|
|
483
541
|
resolveImportedInstance: (alias, name) => this.resolveImportedInstance(alias, name),
|
|
484
542
|
};
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
543
|
+
// Route a pre-resolved boot target through the instrumented chokepoint
|
|
544
|
+
// (`runResolved`) instead of calling `instance.run()` directly, so it emits a
|
|
545
|
+
// run span nested under the app. Kind/name come from the `!ref` identity the
|
|
546
|
+
// kernel stamped at injection, falling back to a positional label.
|
|
547
|
+
const runResolvedInstance = async (inst: ResourceInstance, kind: string, name: string) =>
|
|
548
|
+
this.runResolved(kind, name, inst, targetCtx);
|
|
549
|
+
|
|
550
|
+
if (tracing) await this.emit(`${appLabel}.Running`, appSpan("start", undefined));
|
|
551
|
+
try {
|
|
552
|
+
await this.dispatchTargets(stepCtx, steps, runResolvedInstance, targetCtx);
|
|
553
|
+
if (tracing) await this.emit(`${appLabel}.Run`, appSpan("end", "ok"));
|
|
554
|
+
} catch (err) {
|
|
555
|
+
if (tracing) {
|
|
556
|
+
const cancelled = (err as { code?: unknown })?.code === "ERR_INVOKE_CANCELLED";
|
|
557
|
+
await this.emit(
|
|
558
|
+
`${appLabel}.${cancelled ? "RunCancelled" : "RunFailed"}`,
|
|
559
|
+
appSpan("end", cancelled ? "cancelled" : "failed"),
|
|
493
560
|
);
|
|
494
561
|
}
|
|
495
|
-
|
|
562
|
+
throw err;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private async dispatchTargets(
|
|
567
|
+
stepCtx: InvokeStepContext,
|
|
568
|
+
steps: Record<string, unknown>,
|
|
569
|
+
runResolvedInstance: (inst: ResourceInstance, kind: string, name: string) => Promise<void>,
|
|
570
|
+
ctx?: InvokeContext,
|
|
571
|
+
) {
|
|
572
|
+
// Recover the kind+name the kernel stamped onto a `!ref`-injected instance,
|
|
573
|
+
// so the run span is properly labelled; fall back to a positional name.
|
|
574
|
+
const idOf = (inst: ResourceInstance, fallbackName: string) => {
|
|
575
|
+
const id = getRefIdentity(inst as object);
|
|
576
|
+
return { kind: id?.kind ?? "", name: id?.name ?? fallbackName };
|
|
496
577
|
};
|
|
497
578
|
for (let i = 0; i < this.targets.length; i++) {
|
|
498
579
|
const target = this.targets[i]!;
|
|
@@ -524,7 +605,8 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
524
605
|
// Phase 5 injection may have replaced the ref slot with the live
|
|
525
606
|
// instance; run it directly.
|
|
526
607
|
if (isRunnableInstance(ref)) {
|
|
527
|
-
|
|
608
|
+
const id = idOf(ref, `target[${i}]`);
|
|
609
|
+
await runResolvedInstance(ref, id.kind, id.name);
|
|
528
610
|
} else {
|
|
529
611
|
const r =
|
|
530
612
|
ref && typeof ref === "object"
|
|
@@ -537,7 +619,7 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
537
619
|
`Boot target '${r.alias}.${r.name}' is not a runnable exported instance`,
|
|
538
620
|
);
|
|
539
621
|
}
|
|
540
|
-
await runResolvedInstance(inst,
|
|
622
|
+
await runResolvedInstance(inst, getRefIdentity(inst as object)?.kind ?? "", r.name);
|
|
541
623
|
} else {
|
|
542
624
|
await this.run(typeof ref === "string" ? ref : r!.name, ctx);
|
|
543
625
|
}
|
|
@@ -550,7 +632,8 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
550
632
|
// so the common runtime shape here is a pre-resolved ResourceInstance;
|
|
551
633
|
// fall back to the structural ref forms when injection left them in place.
|
|
552
634
|
if (isRunnableInstance(target)) {
|
|
553
|
-
|
|
635
|
+
const id = idOf(target, `target[${i}]`);
|
|
636
|
+
await runResolvedInstance(target, id.kind, id.name);
|
|
554
637
|
continue;
|
|
555
638
|
}
|
|
556
639
|
const bare = target as { name?: unknown; alias?: unknown };
|
|
@@ -561,7 +644,7 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
561
644
|
`Boot target '${bare.alias}.${bare.name}' is not a runnable exported instance`,
|
|
562
645
|
);
|
|
563
646
|
}
|
|
564
|
-
await runResolvedInstance(inst,
|
|
647
|
+
await runResolvedInstance(inst, getRefIdentity(inst as object)?.kind ?? "", bare.name);
|
|
565
648
|
continue;
|
|
566
649
|
}
|
|
567
650
|
if (typeof bare.name === "string") {
|
package/src/resource-context.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
type InvokeContext,
|
|
13
13
|
type LoadOptions,
|
|
14
14
|
type ModuleContext,
|
|
15
|
+
type OpenSpan,
|
|
16
|
+
type OpenSpanOptions,
|
|
15
17
|
type ParsedArgs,
|
|
16
18
|
type TypeRule,
|
|
17
19
|
} from "@telorun/sdk";
|
|
@@ -155,6 +157,10 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
155
157
|
return createCancellationSource();
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
openSpan(base: InvokeContext | undefined, opts: OpenSpanOptions): Promise<OpenSpan> {
|
|
161
|
+
return this.moduleContext.openSpan(base, opts);
|
|
162
|
+
}
|
|
163
|
+
|
|
158
164
|
invoke<TInputs>(kind: string, name: string, inputs: TInputs): Promise<any> {
|
|
159
165
|
return this.moduleContext.invoke(kind, name, inputs);
|
|
160
166
|
}
|
|
@@ -223,7 +229,7 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
223
229
|
// analyzer's field-map walker descends `oneOf`/`anyOf` variant
|
|
224
230
|
// properties but intentionally early-returns on `$ref` (see
|
|
225
231
|
// `analyzer/nodejs/src/reference-field-map.ts`). Enabling the `$ref`
|
|
226
|
-
// descent regresses the kernel's `<
|
|
232
|
+
// descent regresses the kernel's `<name>.Invoked` event
|
|
227
233
|
// emission for kinds (notably `Run.Sequence`) whose controllers
|
|
228
234
|
// call `instance.invoke()` directly on Phase-5-injected instances;
|
|
229
235
|
// the walker fix needs to land together with routing those callers
|
package/src/tracing.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import type { Tracer } from "@telorun/sdk";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -17,4 +18,10 @@ export class KernelTracer implements Tracer {
|
|
|
17
18
|
this.#next += 1;
|
|
18
19
|
return this.#next;
|
|
19
20
|
}
|
|
21
|
+
|
|
22
|
+
/** A fresh OTel-compatible 16-byte hex trace id. Globally unique, so a trace
|
|
23
|
+
* stays identifiable once it crosses process boundaries. */
|
|
24
|
+
newTraceId(): string {
|
|
25
|
+
return randomBytes(16).toString("hex");
|
|
26
|
+
}
|
|
20
27
|
}
|