@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.
@@ -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"}
@@ -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.30.2",
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.26.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 invokeCtx = ctx ?? ambient ?? UNCANCELLABLE_CONTEXT;
609
- const token = invokeCtx.cancellation;
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(`${kind}.${name}.InvokeCancelled`, { reason: token.reason });
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(`${kind}.${name}.Invoked`, { outputs });
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(`${kind}.${name}.InvokeCancelled`, { reason });
744
+ await this.emit(`${name}.InvokeCancelled`, span("end", "cancelled", { inputs, reason }));
644
745
  throw err;
645
746
  }
646
747
  if (isInvokeError(err)) {
647
- const payload = { code: err.code, message: err.message, data: err.data };
648
- await this.emit(`${kind}.${name}.InvokeRejected`, payload);
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(`${kind}.${name}.InvokeRejected.Undeclared`, payload);
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(`${kind}.${name}.InvokeFailed`, {
657
- name: err.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(`${kind}.${name}.InvokeFailed`, {
662
- name: "UnknownError",
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
- const ambient = cancellationStore.getStore();
705
- const invokeCtx = ctx ?? ambient ?? UNCANCELLABLE_CONTEXT;
706
- const token = invokeCtx.cancellation;
707
- // Refuse a target reached after the boot run was cancelled.
708
- if (token.isCancelled) {
709
- await this.emit(`${entry.resource.kind}.${name}.RunCancelled`, { reason: token.reason });
710
- throw new RuntimeError(
711
- "ERR_INVOKE_CANCELLED",
712
- `Run ${entry.resource.kind}.${name} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
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
- // Run inside the scope so the runnable's nested invokes inherit the token,
716
- // and pass it explicitly so long-lived targets can observe cancellation.
717
- // Skip the redundant `run` when the token is already the ambient one.
718
- const call = () => (entry.instance.run as (c?: InvokeContext) => Promise<void>)(invokeCtx);
719
- return invokeCtx === ambient ? call() : cancellationStore.run(invokeCtx, call);
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
  }