@telorun/http-server 0.11.1 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a8c99ab: Generic dispatch tracing: trace every capability dispatch (invoke and run) through one instrumented chokepoint and carry trace data in a structured event payload instead of the event name.
8
+
9
+ - Dispatch events drop the kind from the name (`<name>.Invoked` / `.Run`, plus error/cancel variants). The payload now carries `{ spanId, parentSpanId, capability, phase, outcome, ref: { kind, name }, … }`; consumers read the payload and never parse the dotted name. Lifecycle events (`Kind.name.Created` / `.Initialized` / `.Teardown`) are unchanged.
10
+ - `run()` is now span-instrumented like `invoke()`: it mints and propagates a trace id, so Runnables (e.g. a `Run.Sequence` boot target) appear in the trace and their nested invokes re-parent correctly instead of detaching as false roots. Long-lived Services emit a `<name>.Running` start span. Run failures emit `<name>.RunFailed` (rethrown, never swallowed).
11
+ - Invoke/run emit a `<name>.Invoking` / `.Running` start span when tracing is on.
12
+ - SDK: new `REF_IDENTITY` / `stampRefIdentity` / `getRefIdentity`. The kernel stamps a resolved `!ref`'s kind+name onto the injected instance so `executeInvokeStep` routes pre-injected live instances through the traced chokepoint instead of calling `.invoke()` directly and escaping instrumentation.
13
+ - The boot `targets` run is wrapped in an application span (`<appName>.Run`, `ref.kind: "Telo.Application"`), so the application is the trace root with its targets nested beneath. Pre-resolved `!ref` boot targets now dispatch through a new `EvaluationContext.runResolved` (the `run()` analog of `invokeResolved`) instead of calling `instance.run()` directly, so they emit their own run spans nested under the app.
14
+ - A `Telo.Service`'s long-lived `run()` no longer establishes the cancellation/trace ALS scope (its token is delivered via the explicit `run(invokeCtx)` argument instead). This stops the boot scope leaking onto async resources the service creates — e.g. an HTTP server's socket — so inbound work (each request) starts as its own root trace with no inherited boot cancellation token, instead of nesting under the bootstrap trace. Runnables keep the ALS scope so their steps still nest and inherit cancellation.
15
+ - `EventBus.emit` short-circuits in O(1) when there are no subscribers, keeping the always-through-the-chokepoint dispatch effectively free when nobody is listening.
16
+ - OpenTelemetry-ready trace model: every span carries a `traceId` (OTel-compatible 16-byte hex), minted at the root and inherited by descendants, so an exporter groups a trace without walking the parent chain. New generic `ctx.openSpan(base, { ref, label, attributes, inbound? })` primitive opens an inbound-boundary span (capability `"request"`) that roots its own trace; `inbound` allows continuing an upstream distributed trace later. The `TracePayload` gains `traceId`, `label`, and `attributes`.
17
+ - `http-server`: each inbound request opens a request span attributed to the `Http.Api` and labelled with the route (`"POST /feedback"`, attributes `{ method, path }`); the handler invoke and its subtree nest under it, as a trace separate from the bootstrap.
18
+ - Trace context capture: a trace's root span carries `payload.context` — a redacted snapshot of the CEL root scope available to the trace (`variables`, `resources` snapshots, `ports`, and `secrets` with values masked to `"[secret]"`; host `env` omitted). Lets a debug consumer see what data an execution could reference beyond its own inputs/outputs. The UI renders it as an "Available context" section on the root node.
19
+
20
+ ### Patch Changes
21
+
22
+ - @telorun/http-dispatch@0.4.1
23
+
3
24
  ## 0.11.1
4
25
 
5
26
  ### Patch Changes
package/README.md CHANGED
@@ -24,8 +24,8 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
24
24
  kind: Telo.Application
25
25
  metadata: { name: hello-http, version: 1.0.0 }
26
26
  imports:
27
- Http: std/http-server@0.12.0
28
- JS: std/javascript@0.5.0
27
+ Http: std/http-server@<version>
28
+ JS: std/javascript@<version>
29
29
  targets: [ !ref Server ]
30
30
  ---
31
31
  kind: Http.Server
@@ -51,7 +51,8 @@ export declare class HttpServerApi implements ResourceInstance {
51
51
  private readonly ctx;
52
52
  readonly manifest: HttpApiManifest;
53
53
  private readonly handlerRefs;
54
- constructor(ctx: ResourceContext, manifest: HttpApiManifest, handlerRefs: WeakMap<object, HandlerRef>);
54
+ private readonly apiName;
55
+ constructor(ctx: ResourceContext, manifest: HttpApiManifest, handlerRefs: WeakMap<object, HandlerRef>, apiName: string);
55
56
  init(): Promise<void>;
56
57
  register(app: FastifyInstance, prefix?: string): void;
57
58
  private registerRoutes;
@@ -26,10 +26,12 @@ export class HttpServerApi {
26
26
  ctx;
27
27
  manifest;
28
28
  handlerRefs;
29
- constructor(ctx, manifest, handlerRefs) {
29
+ apiName;
30
+ constructor(ctx, manifest, handlerRefs, apiName) {
30
31
  this.ctx = ctx;
31
32
  this.manifest = manifest;
32
33
  this.handlerRefs = handlerRefs;
34
+ this.apiName = apiName;
33
35
  }
34
36
  async init() { }
35
37
  register(app, prefix = "") {
@@ -118,22 +120,35 @@ export class HttpServerApi {
118
120
  if (!reply.sent)
119
121
  cancellation.cancel("client-disconnect");
120
122
  });
123
+ // Open a request span rooting this request's own trace: the handler (and
124
+ // its nested invokes) nest under it, and it's labelled with the route so
125
+ // the trace shows the actual method+path, attributed to this Http.Api.
126
+ const span = await this.ctx.openSpan(cancellation.context, {
127
+ ref: { kind: "Http.Api", name: this.apiName },
128
+ label: `${route.request.method} ${route.request.path}`,
129
+ attributes: { method: route.request.method, path: route.request.path },
130
+ });
121
131
  let result;
122
132
  try {
123
133
  result = handler
124
- ? await this.ctx.invokeResolved(handlerKind, handlerName, handler, invokeInput, cancellation.context)
134
+ ? await this.ctx.invokeResolved(handlerKind, handlerName, handler, invokeInput, span.context)
125
135
  : undefined;
126
136
  }
127
137
  catch (err) {
128
138
  if (isCancellationError(err)) {
139
+ await span.settle("cancelled");
129
140
  if (!reply.sent)
130
141
  reply.code(499).send();
131
142
  return;
132
143
  }
133
- if (!isInvokeError(err))
144
+ if (!isInvokeError(err)) {
145
+ await span.settle("failed");
134
146
  throw err;
147
+ }
148
+ await span.settle("rejected");
135
149
  return dispatchCatches(route.catches, { code: err.code, message: err.message, data: err.data }, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink);
136
150
  }
151
+ await span.settle("ok");
137
152
  return dispatchReturns(route.returns, result, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink, (err, errCtx) => this.ctx.emitEvent("Http.Api.streamFailed", {
138
153
  path: route.request.path,
139
154
  method: route.request.method,
@@ -169,7 +184,7 @@ export async function create(resource, ctx) {
169
184
  handlerRefs.set(route, { kind: "", name: h });
170
185
  }
171
186
  }
172
- return new HttpServerApi(ctx, resource, handlerRefs);
187
+ return new HttpServerApi(ctx, resource, handlerRefs, resource?.metadata?.name ?? "");
173
188
  }
174
189
  /**
175
190
  * Translates OpenAPI path format {paramName} to Fastify format :paramName
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -49,7 +49,7 @@
49
49
  "@types/node": "^20.0.0",
50
50
  "typescript": "^5.0.0",
51
51
  "vitest": "^2.1.8",
52
- "@telorun/sdk": "0.31.0"
52
+ "@telorun/sdk": "0.32.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@telorun/sdk": "*"
@@ -59,6 +59,7 @@ export class HttpServerApi implements ResourceInstance {
59
59
  private readonly ctx: ResourceContext,
60
60
  readonly manifest: HttpApiManifest,
61
61
  private readonly handlerRefs: WeakMap<object, HandlerRef>,
62
+ private readonly apiName: string,
62
63
  ) {}
63
64
 
64
65
  async init() {}
@@ -159,6 +160,15 @@ export class HttpServerApi implements ResourceInstance {
159
160
  if (!reply.sent) cancellation.cancel("client-disconnect");
160
161
  });
161
162
 
163
+ // Open a request span rooting this request's own trace: the handler (and
164
+ // its nested invokes) nest under it, and it's labelled with the route so
165
+ // the trace shows the actual method+path, attributed to this Http.Api.
166
+ const span = await this.ctx.openSpan(cancellation.context, {
167
+ ref: { kind: "Http.Api", name: this.apiName },
168
+ label: `${route.request.method} ${route.request.path}`,
169
+ attributes: { method: route.request.method, path: route.request.path },
170
+ });
171
+
162
172
  let result: unknown;
163
173
  try {
164
174
  result = handler
@@ -167,15 +177,20 @@ export class HttpServerApi implements ResourceInstance {
167
177
  handlerName,
168
178
  handler,
169
179
  invokeInput,
170
- cancellation.context,
180
+ span.context,
171
181
  )
172
182
  : undefined;
173
183
  } catch (err) {
174
184
  if (isCancellationError(err)) {
185
+ await span.settle("cancelled");
175
186
  if (!reply.sent) reply.code(499).send();
176
187
  return;
177
188
  }
178
- if (!isInvokeError(err)) throw err;
189
+ if (!isInvokeError(err)) {
190
+ await span.settle("failed");
191
+ throw err;
192
+ }
193
+ await span.settle("rejected");
179
194
  return dispatchCatches(
180
195
  route.catches,
181
196
  { code: err.code, message: err.message, data: err.data },
@@ -187,6 +202,7 @@ export class HttpServerApi implements ResourceInstance {
187
202
  );
188
203
  }
189
204
 
205
+ await span.settle("ok");
190
206
  return dispatchReturns(
191
207
  route.returns,
192
208
  result,
@@ -232,7 +248,7 @@ export async function create(resource: any, ctx: ResourceContext): Promise<HttpS
232
248
  handlerRefs.set(route, { kind: "", name: h });
233
249
  }
234
250
  }
235
- return new HttpServerApi(ctx, resource, handlerRefs);
251
+ return new HttpServerApi(ctx, resource, handlerRefs, resource?.metadata?.name ?? "");
236
252
  }
237
253
 
238
254
  /**
@@ -48,6 +48,7 @@ describe("http-server request cancellation", () => {
48
48
  invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown, c: unknown) =>
49
49
  h.invoke(input, c as { cancellation?: any }),
50
50
  emitEvent: () => {},
51
+ openSpan: async (base: unknown) => ({ context: base, settle: async () => {} }),
51
52
  } as unknown as Parameters<typeof create>[1];
52
53
 
53
54
  const resource = {
@@ -0,0 +1,82 @@
1
+ import { createCancellationSource } from "@telorun/sdk";
2
+ import Fastify from "fastify";
3
+ import type { AddressInfo } from "node:net";
4
+ import { describe, expect, it } from "vitest";
5
+ import { create } from "../src/http-api-controller.js";
6
+
7
+ /**
8
+ * Each inbound request opens a trace span attributed to the Http.Api and labelled
9
+ * with the route, under which the handler invoke nests. This drives the real
10
+ * controller (real Fastify) with a mock ResourceContext that captures the
11
+ * `openSpan` call, asserting the controller passes the route's `{kind,name}`,
12
+ * label and `{method,path}` attributes — and settles the span on success.
13
+ *
14
+ * The generic span mechanics (rooting a detached trace, nesting, trace id) are a
15
+ * kernel concern, tested there; this test owns only the HTTP-specific wiring.
16
+ */
17
+ describe("http-server request span", () => {
18
+ it("opens a route-labelled span and dispatches the handler under it", async () => {
19
+ const spanCalls: any[] = [];
20
+ const settled: string[] = [];
21
+
22
+ const handler = {
23
+ async invoke(input: unknown) {
24
+ return { echoed: (input as { value: number }).value };
25
+ },
26
+ snapshot: () => ({}),
27
+ };
28
+
29
+ const ctx = {
30
+ validateSchema: () => {},
31
+ resolveChildren: () => ({ kind: "JS.Script", name: "Echo" }),
32
+ moduleContext: { expandWith: (value: unknown) => value },
33
+ createCancellationSource: () => createCancellationSource(),
34
+ invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown) =>
35
+ h.invoke(input),
36
+ emitEvent: () => {},
37
+ openSpan: async (base: unknown, opts: unknown) => {
38
+ spanCalls.push(opts);
39
+ return {
40
+ context: base,
41
+ settle: async (outcome: string) => {
42
+ settled.push(outcome);
43
+ },
44
+ };
45
+ },
46
+ } as unknown as Parameters<typeof create>[1];
47
+
48
+ const resource = {
49
+ metadata: { name: "EchoApi", module: "test" },
50
+ routes: [
51
+ {
52
+ request: { path: "/echo", method: "GET" },
53
+ handler,
54
+ inputs: { value: 1 },
55
+ returns: [{ status: 200, content: { "application/json": { body: { ok: true } } } }],
56
+ },
57
+ ],
58
+ };
59
+
60
+ const api = await create(resource, ctx);
61
+ const app = Fastify({ logger: false });
62
+ api.register(app);
63
+ await app.listen({ host: "127.0.0.1", port: 0 });
64
+ const { port } = app.server.address() as AddressInfo;
65
+
66
+ try {
67
+ const res = await fetch(`http://127.0.0.1:${port}/echo`);
68
+ expect(res.status).toBe(200);
69
+ await res.json();
70
+
71
+ expect(spanCalls).toHaveLength(1);
72
+ expect(spanCalls[0]).toEqual({
73
+ ref: { kind: "Http.Api", name: "EchoApi" },
74
+ label: "GET /echo",
75
+ attributes: { method: "GET", path: "/echo" },
76
+ });
77
+ expect(settled).toEqual(["ok"]);
78
+ } finally {
79
+ await app.close().catch(() => {});
80
+ }
81
+ });
82
+ });