Observability & Tracing
You want per-module latency and error attribution without threading a tracer through every handler — and you want it good enough to answer "which module is slow?" before you decide what to extract into its own service.
@heximon/otel gives you exactly that with one line of config and zero application code: the compiler already
resolved the owning module of every controller, command handler, query handler, and event handler at build time,
so the plugin bakes heximon.module onto every span as a string literal.
There is no runtime class-to-module lookup — and there is no other framework where the trace already knows which module produced the span, because no runtime-DI container carries that mapping.
The mental model: the module is baked, not looked up
A span needs three things to be useful for attribution: a name, a kind, and the owning module. Heximon's compiler
resolves the owning module of every dispatched handler while it builds the static DI graph. @heximon/otel reads
that graph and emits a key → span model map into a generated otel.wiring.js — the module name is a frozen
string literal in the output, established at build time:
// Per-dispatch-key span model baked at compile time: span name, kind, and module-attributed
// attributes for every command, query, event, and queue handler the dispatch tiers published.
const otelSpanModels = {
"CreateOrderCommand#a0892bac": {
"spanName": "CommandBus.dispatch CreateOrderCommand",
"spanKind": "INTERNAL",
"attributes": {
"messaging.system": "heximon.cqrs",
"messaging.operation": "process",
"heximon.message.type": "CreateOrderCommand",
"heximon.module": "AppModule",
"heximon.handler": "CreateOrderHandler"
}
}
};
That's the whole differentiator. Because "heximon.module": "AppModule" is a literal in the generated wiring,
the runtime span carries it for free — no reflection, no container walk, no decorator.
Group your traces by heximon.module and you get per-module p99 latency and error rates straight from your collector. When one module
consistently dominates the latency or owns the errors, that's your microservice-extraction signal — sourced from
the trace, not from guesswork.
Add the plugin and set the otel key — the only step
Tracing is off by default: nothing from @heximon/otel is imported unless you register the plugin. Register
new OtelPlugin() in the plugins array and set the top-level otel config key — an augmentation
@heximon/otel/compiler adds to the heximon.config.ts shape, read by the plugin at build time (the plugin
takes no constructor argument):
import { defineHeximonConfig } from "@heximon/build";
import { CqrsPlugin } from "@heximon/cqrs/compiler";
import { EventEmitterPlugin } from "@heximon/events/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
import { OtelPlugin } from "@heximon/otel/compiler";
export default defineHeximonConfig({
plugins: [new HttpPlugin(), new CqrsPlugin(), new EventEmitterPlugin(), new OtelPlugin()],
otel: {
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
protocol: "http",
resourceAttributes: { "deployment.environment": "development" },
batchOptions: { exportTimeoutMillis: 300 },
},
});
OtelPlugin owns no module-config namespace key — it discovers nothing from your modules, and it reads its
configuration from the top-level otel key rather than a constructor argument.
Instead it reads the HTTP, CQRS, and event dispatch graphs the other plugins publish, and bakes a heximon.module-attributed span model into the
generated otel.wiring.js. The HTTP server span, the command/query dispatch spans, and the serial event-handler
spans are all produced with no further configuration.
OtelPlugin but omits a tier (say, no CQRS)
emits no wiring for that tier — its generated output is byte-identical to an app with no OtelPlugin. You only
pay for the tiers you actually run.Zero application code at the dispatch seam
The spans wrap your handlers without any of them knowing. The controller dispatches CQRS messages, the command
handler emits an event — and not one of them imports anything from @heximon/otel. This is the controller from
the gap's otel-node example:
public async place(action: Post<"/", { body: PlaceOrderBody }>): Promise<Order> {
const body = await action.request.readValidatedBody();
const id = OrderId.generate();
await this.commandBus.execute(
new PlaceOrderCommand(id, body.customerEmail, body.product, body.quantity),
);
const order = await this.queryBus.execute(new GetOrderQuery(id));
if (!order) {
throw new NotFoundError(`Order ${id} not found`);
}
action.response.status = 201;
return order;
}
The command handler persists the order and emits a class-tier event on the injected EventBus — the third tier
on the same trace:
public override async handle(command: PlaceOrderCommand): Promise<void> {
const order: Order = {
id: command.id,
customerEmail: command.customerEmail,
product: command.product,
quantity: command.quantity,
confirmed: false,
};
this.orderStore.save(order);
// OrderPlacedEvent extends BaseEvent, so the live instance is emittable with no cast.
await this.eventBus.emitAsync(
new OrderPlacedEvent(command.id, command.customerEmail, command.product, command.quantity),
);
}
And the serial event handler reacts — also untouched by tracing:
export class ConfirmOrderHandler implements EventHandler<OrderPlacedEvent> {
constructor(private readonly orderStore: OrderStore) {}
public handle(event: OrderPlacedEvent): void {
const order = this.orderStore.findById(event.orderId);
if (order !== undefined) {
this.orderStore.save({ ...order, confirmed: true });
}
}
}
One POST /orders crosses all three instrumented tiers: the HTTP server span wraps the command-dispatch span,
which wraps the event-emit span — each attributed to OrdersModule, all produced from the generated wiring.
Adding your own spans — inject Tracer, don't import the SDK
Auto-instrumentation covers the dispatch seam; for a custom span around your own unit of work, inject the
Tracer facade @heximon/otel provides. It's a normal DI token — constructor-injected like any dependency, so
you never reach into @opentelemetry/api. This is the query handler from the gap's otel-node example:
import type { QueryHandler } from "@heximon/cqrs";
import { Tracer } from "@heximon/otel";
import type { Order } from "../order";
import { OrderStore } from "../order.store";
import type { GetOrderQuery } from "./get-order.query";
export class GetOrderQueryHandler implements QueryHandler<GetOrderQuery> {
constructor(
private readonly orderStore: OrderStore,
private readonly tracer: Tracer,
) {}
public async handle(query: GetOrderQuery): Promise<Order | undefined> {
return this.tracer.span("orders.lookup", async (span) => {
span.setAttribute("order.id", query.id);
return this.orderStore.findById(query.id);
});
}
}
tracer.span(name, fn, options?) runs fn inside an active span and applies the same discipline the
auto-instrumentation uses: the span status is set to OK on success, the exception is recorded with status
ERROR on a throw (then re-thrown), and the span is ended in a finally either way.
Because the CQRS tier already opened the active QueryBus.dispatch GetOrderQuery span, the orders.lookup span nests under it
automatically — same trace, no parent passed by hand. The span's heximon.module comes from its parent; the
facade is an ergonomic win, not a second attribution mechanism.
Tracer requires OtelPlugin. The token is satisfied only by the provider OtelPlugin
contributes, so a class that injects Tracer in an app without the plugin fails to compile —
no provider for 'Tracer', exactly like injecting CommandBus without the CQRS plugin.A precompiled library that injects Tracer declares this in its manifest, so importing it into an app that omits
OtelPlugin is a clear build error — the boundary check reports the missing required plugin by name —
rather than a confusing downstream failure.If you need a tracer in code that must not depend on the
plugin being on — a shared library, or instrumentation you leave in when tracing is compiled out — use the
global @opentelemetry/apitrace.getTracer(...) instead: it no-ops safely when no provider is registered.What you get — the spans
Four dispatch tiers are instrumented, each span carrying heximon.module plus a messaging.* set and the message
type. The attribute names below are exactly what the plugin bakes into otel.wiring.js:
| Tier | Span name | Span kind | Key attributes |
|---|---|---|---|
| HTTP request | <METHOD> <path> | SERVER | http.request.method, heximon.module, heximon.request.id |
| CQRS command | CommandBus.dispatch <Command> | INTERNAL | messaging.system: "heximon.cqrs", heximon.message.type, heximon.module, heximon.handler |
| CQRS query | QueryBus.dispatch <Query> | INTERNAL | messaging.system: "heximon.cqrs", heximon.message.type, heximon.module, heximon.handler |
| Serial event | EventBus.emit <Event> | INTERNAL | messaging.system: "heximon.events", heximon.message.type, heximon.module |
| Queue channel | queue.consume <channel> | CONSUMER | messaging.system: "heximon.queue", messaging.destination.name, heximon.module |
The HTTP server span is produced by a RouteDispatcher.configure({ onRequestStart, onRequestEnd }) call in the
generated wiring, reading heximon.module from the route descriptor:
const span = httpTracer.startSpan(
`${request.method} ${descriptor.path ?? ""}`,
{
kind: SpanKind.SERVER,
attributes: {
"http.request.method": request.method,
"heximon.module": descriptor.moduleName ?? "",
"heximon.request.id": requestId,
},
},
parentContext,
);
Each command/query/event/queue span is started by a per-key OtelInterceptor the plugin splices into that tier's
dispatch bus steps. The interceptor extracts any incoming W3C traceparent / tracestate from the message before
starting its child span, so cross-process and cross-tier traces stay connected on one trace id.
Configuration — OtelConfig
Tracing is configured by the top-level otel key, not a constructor argument; every field is optional, so
new OtelPlugin() is always valid and an omitted otel key bakes the defaults. The same shape applies to both
the Node and Workers providers:
| Field | Type | Description |
|---|---|---|
endpoint | string | The OTLP collector endpoint URL. Tracing is opt-in at build time — omit it and the plugin emits LEAN wiring: no OpenTelemetry SDK is imported or bundled (you declare none of its deps and pay no bundle weight or boot cost) and no auto-spans are baked; the injectable Tracer stays available as a no-op, so dispatch is byte-identical to a no-OtelPlugin app. Set it — even to the local collector default (http://localhost:4318) — to enable tracing + bake the full provider wiring. |
protocol | "http" | "proto" | "grpc" | OTLP transport: "http" (OTLP/HTTP JSON), "proto" (OTLP/HTTP Protobuf), "grpc" (Node-only native binding). Defaults to "http". |
resourceAttributes | Record<string, string> | Static Resource attributes merged at provider boot — deployment.environment, service.version, cloud.region. Baked as compile-time literals, so they must be known at build time. |
samplerRatio | number | Head-sampling ratio for root spans, 0..1. Applied via ParentBasedSampler({ root: TraceIdRatioBasedSampler(ratio) }): a root span is kept with this probability, a child follows its parent. Omitted ⇒ keep every root span. |
batchOptions | BatchOptions | BatchSpanProcessor tuning for the Node provider: maxQueueSize, maxExportBatchSize, scheduledDelayMillis, exportTimeoutMillis. |
service.name is resolved from your CoreConfig at runtime and does not need to be repeated in
resourceAttributes. samplerRatio is a plain numeric knob rather than a Sampler instance because the value is
baked as a compile-time literal into the generated wiring — a function or object sampler cannot be serialized into
source, so the provider constructs the sampler from this ratio.
batchOptions.exportTimeoutMillis is worth setting in development: it bounds how long the shutdown flush waits for
a span export to settle. With no collector reachable, a small value (the example uses 300) makes the export fail
fast rather than stalling on the exporter's default 30 s retry budget, so a graceful restart is not delayed.
The flush failure is caught and logged on shutdown, never surfacing as a crash — the app needs no running collector to shut down cleanly.
Node vs edge — the build target picks the tracer leg
The OTel SDK ships two very different runtimes, and shipping the wrong one to the edge is expensive. Which leg
the plugin bakes is decided by the compiler's build target — the targetClass of the deploy strategy on
heximon.platform — a single axis that captures the one difference codegen cares about: flush at process
shutdown, or flush per request.
target | Tracer leg | Span processor | Flush |
|---|---|---|---|
"node" (default) | the Node SDK | BatchSpanProcessor | drained at graceful shutdown |
"edge" | the lean Workers leg | SimpleSpanProcessor | drained per request via the platform waitUntil |
You usually don't pick the leg by hand — it is the runtime-lifetime class of your deploy strategy, and a
deploy host derives that from its preset. The Nitro module maps any cloudflare-*, *-edge, or deno-deploy
preset to the edge class and every node/node-compatible preset to node, for both nitro build and nitro dev.
On the host-independent @heximon/build path, an edge-class strategy (e.g. CloudflareWorkersStrategy) selects
the Workers leg — set it on heximon.config.ts:
// heximon.config.ts
import { defineHeximonConfig } from "@heximon/build";
import { CloudflareWorkersStrategy } from "@heximon/cloudflare";
import { OtelPlugin } from "@heximon/otel/compiler";
export default defineHeximonConfig({
platform: new CloudflareWorkersStrategy(), // an edge-class strategy → the Workers tracer leg
plugins: [new OtelPlugin()],
otel: { endpoint: "https://otlp.example.com/v1/traces" },
});
On "edge", the generated wiring builds the span Resource in the factory, constructs the per-isolate tracer
once at boot, and — inside the same HTTP server-span hook — hands the span flush to the kernel's
context.waitUntil after the span ends.
The HTTP request frame carries the platform waitUntil sink (the HTTP layer threads it off the runtime's
request.waitUntil), so the flush is forwarded to ctx.waitUntil: exporting never blocks the response and the
isolate stays alive long enough to drain.
The edge bundle is far leaner: a representative full app's OTLP/HTTP edge bundle is ~77 KB gzip against ~234 KB
gzip for the Node SDK leg. The samplerRatio and batchOptions knobs above are Node-leg tuning; the edge leg's
SimpleSpanProcessor ignores them.
Real coverage limits
heximon.module attribution covers the four tiers above. Three dispatch paths are deliberately out of scope —
not from oversight, but because no interceptor seam reaches them, and a wrong attribution would be worse than a
missing one:
- Parallel notification handlers are not traced.
NotificationHandler(parallel-absorb) bypasses the interceptor chain entirely, so no span is produced for it. - String-tier events are not traced.
EventBus.emitStringAsyncbypasses the shared dispatch substrate the interceptor splices into, so string-keyed events are untraced. Use class-tier events (theextends EventHandler<Event>form) for tracing. - A multi-module event attributes to no single module. Serial event dispatch is wrapped per handler, not per
event: an event fanned out to handlers in several modules produces one span per handler invocation, each
attributed to its own module. When the owning module is ambiguous,
heximon.moduleis omitted rather than set to a wrong value — a missing attribute doesn't assert something false; a wrong one corrupts your per-module rollups.
Both legs are production-ready and selected by the build target (above): "node" bakes the batch-backed Node
SDK with a shutdown flush, "edge" bakes the lean Workers leg with a per-request ctx.waitUntil flush. The
per-request edge flush rides the HTTP request lifecycle; a non-HTTP edge entrypoint (queue/cron/Durable Object
RPC) flushes through the host's own executionContext.waitUntil.
See also
- OpenTelemetry Tracing (Node)
— a multi-tier orders app where one
POST /orderscrosses the HTTP, CQRS command, and serial event spans, all attributed toOrdersModule, plus an in-process e2e test that proves the app survives a collector that is down (the flush fails, the lifecycle catches it, dispatch is unchanged). - CQRS — the
CommandBus/QueryBusdispatch the command and query spans wrap. - Events — the serial
EventHandlertier the event spans wrap. - Packages overview — where
@heximon/otelsits in the stack.
Security Hardening
Body-size guard (content-length, 413 RFC-9457), SecurityHeaders (nosniff/DENY/no-referrer/HSTS), RateLimitStore port, MemoryRateLimitStore, RateLimitMiddleware, @heximon/http/security, bearer-only CSRF posture.
OpenAPI
Generate an OpenAPI 3.1 document from your Contract route tables at runtime with OpenApiGenerator, register security schemes manually with the SecurityScheme factory, and serve Scalar or Swagger UI inline.