@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.
@@ -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 on every event's
623
- // `metadata` so the consumer can rebuild the call tree. Off by default —
624
- // `tracedCtx` stays `baseCtx` and the fast `=== ambient` skip is preserved.
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
- const meta = tracing ? { invocationId, parentInvocationId } : undefined;
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(`${kind}.${name}.InvokeCancelled`, { inputs, reason: token.reason }, meta);
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(`${kind}.${name}.Invoked`, { inputs, outputs }, meta);
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(`${kind}.${name}.InvokeCancelled`, { inputs, reason }, meta);
744
+ await this.emit(`${name}.InvokeCancelled`, span("end", "cancelled", { inputs, reason }));
672
745
  throw err;
673
746
  }
674
747
  if (isInvokeError(err)) {
675
- const payload = { inputs, code: err.code, message: err.message, data: err.data };
676
- await this.emit(`${kind}.${name}.InvokeRejected`, payload, meta);
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(`${kind}.${name}.InvokeRejected.Undeclared`, payload, meta);
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
- `${kind}.${name}.InvokeFailed`,
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
- `${kind}.${name}.InvokeFailed`,
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
- const ambient = cancellationStore.getStore();
735
- const invokeCtx = ctx ?? ambient ?? UNCANCELLABLE_CONTEXT;
736
- const token = invokeCtx.cancellation;
737
- // Refuse a target reached after the boot run was cancelled.
738
- if (token.isCancelled) {
739
- await this.emit(`${entry.resource.kind}.${name}.RunCancelled`, { reason: token.reason });
740
- throw new RuntimeError(
741
- "ERR_INVOKE_CANCELLED",
742
- `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),
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
- // Run inside the scope so the runnable's nested invokes inherit the token,
746
- // and pass it explicitly so long-lived targets can observe cancellation.
747
- // Skip the redundant `run` when the token is already the ambient one.
748
- const call = () => (entry.instance.run as (c?: InvokeContext) => Promise<void>)(invokeCtx);
749
- 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;
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
 
@@ -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, ctx),
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, ctx),
540
+ this.invokeResolved(kind, name, instance, inputs, targetCtx),
483
541
  resolveImportedInstance: (alias, name) => this.resolveImportedInstance(alias, name),
484
542
  };
485
- // Mirror the local-run gate: refuse a target reached after the boot run was
486
- // cancelled, then run the pre-resolved instance directly.
487
- const runResolvedInstance = async (inst: ResourceInstance, label: string) => {
488
- const token = ctx?.cancellation;
489
- if (token?.isCancelled) {
490
- throw new RuntimeError(
491
- "ERR_INVOKE_CANCELLED",
492
- `Run ${label} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
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
- await inst.run!(ctx);
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
- await runResolvedInstance(ref, `target[${i}]`);
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, `${r.alias}.${r.name}`);
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
- await runResolvedInstance(target, `target[${i}]`);
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, `${bare.alias}.${bare.name}`);
647
+ await runResolvedInstance(inst, getRefIdentity(inst as object)?.kind ?? "", bare.name);
565
648
  continue;
566
649
  }
567
650
  if (typeof bare.name === "string") {
@@ -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 `<Kind>.<Name>.Invoked` event
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
  }