From 8ea3d4f91c35ccb79954be2bf2464ec4ab1366ea Mon Sep 17 00:00:00 2001 From: Devol Date: Mon, 7 Sep 2026 21:25:53 +0200 Subject: [PATCH 1/4] feat: add the 1.3 announcement post Sections in the order the release actually matters: the cheaper request path, the queue module, devtools rebuilt as a telemetry consumer, then the telemetry that all three sit on, with introspection and the logger hooks after it because both read as footnotes to telemetry. The performance section reports 1.2.1 against 1.3 from a run of its own, alternating the two versions on one machine: +3.1% on a route with parameters, +2.5% on a JSON body, and the plain route unchanged, which is the honest tell since neither change can reach a static route that takes no arguments. It also carries the two costs: the bundle grew 267.6 KB to 294.5 KB, and the route matcher compiles on first use through `new Function`. Still needs a video: `public/video/devtools/Queues.mp4` does not exist yet, and the section links it. There is a TODO comment above the tag. --- content/blog/3.announcing-1.3.md | 313 +++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 content/blog/3.announcing-1.3.md diff --git a/content/blog/3.announcing-1.3.md b/content/blog/3.announcing-1.3.md new file mode 100644 index 0000000..bc841d5 --- /dev/null +++ b/content/blog/3.announcing-1.3.md @@ -0,0 +1,313 @@ +--- +title: Announcing Vercube 1.3 +description: 'A cheaper request path, a background job module with four transports, devtools rebuilt as a telemetry consumer, and OpenTelemetry across the whole framework.' +category: Release +author: + name: OskarLebuda + avatar: + src: https://github.com/OskarLebuda.png + alt: Oskar Lebuda + to: https://x.com/OskarLebuda + twitter: OskarLebuda +date: 2026-09-07T00:00:00.000Z +minRead: 12 +--- + +**Vercube 1.3 is out.** A request that carries arguments got measurably cheaper to serve, [`@vercube/queue`](https://www.npmjs.com/package/@vercube/queue) runs work outside the request that asked for it, and devtools no longer collect anything of their own. That last one follows from the change underneath: [`@vercube/telemetry`](https://www.npmjs.com/package/@vercube/telemetry) turns the framework into an OpenTelemetry producer, and devtools became one of its consumers rather than a collector in its own right. + +## A faster request + +Handler arguments and route matching both got cheaper. Measured against 1.2.1 +with the same benchmark, on the same machine, four interleaved rounds per route, +on Node: + +| route | 1.2.1 | 1.3 | | +| --- | --- | --- | --- | +| `GET /` | 101,729 | 102,004 | unchanged | +| `GET /id/:id?name=` | 96,949 | **100,095** | **+3.1%** | +| `POST /json` | 89,598 | **91,817** | **+2.5%** | + +Medians of four rounds. On the JSON body every one of the four rounds came out +ahead of every 1.2.1 round; on the query route three of four did. This was a run +of its own, alternating the two versions; the chart on the home page comes from +a run of the whole suite, so its absolute numbers sit a little differently. + +The plain route not moving is the tell rather than a disappointment: it takes no +arguments and its path is static, so neither change can reach it. What changed +is what a route pays for having parameters, and there the framework's own share +of a request is now a fraction of what it was. Per request, measured in CPU +profiles under load, argument resolution went from 448ns to about 50ns and route +matching from 367ns to about 165ns. + +Three changes did that. **Argument resolvers are compiled once per route** +instead of dispatched on every request: the switch on the argument type and the +optional chaining that read a parameter's name out of the metadata were being +redone per request for an answer fixed when the route was registered. +**`@QueryParam` scans the search string** rather than building a +`URLSearchParams`, which parses and decodes every key and every value of the +whole query in order to hand back one of them. And **rou3's compiler now sees +only the parameterised routes**, because a static path is answered from the +router's own lookup map before rou3 is reached; its generated matcher tests +every static route by string comparison before it splits the path, so a request +for `/id/1` was walking all of them first. + +In isolation those are much larger than 3%. Resolving a path parameter next to a +query parameter went from 430ns to 297ns, reading a JSON body from 3266ns to +2825ns, matching a route with one parameter from 80ns to 42ns and one with three +from 149ns to 45ns. The reason roughly 580ns of removed work turns into 2 to 3% +end to end is that about 4 of the 9.7 microseconds a request costs on that +machine is `writev` and Node's HTTP path, which nothing a framework does can +touch. + +Two honest footnotes. The bundle grew from 267.6 KB to 294.5 KB, because +introspection and the telemetry seam are code that was not there before. And the +route matcher is compiled on first use through `new Function`, which costs about +0.5ms for a 228-route table and lands in the first request; a runtime that +refuses code generation falls back to walking the trie, which is what 1.2 did +for every request anyway. + + +## Queues + +[`@vercube/queue`](https://www.npmjs.com/package/@vercube/queue) runs work outside the request that asked for it. You publish a job with one call, handle it with one decorator, and the module owns everything that should not depend on your broker: routing a job to its handler, validating its payload, retrying it, timing it out and reporting what happened. + +::code-group + +```ts [vercube.config.ts] +import { defineConfig } from '@vercube/core'; +import { defineQueueStrategy, QueuePlugin } from '@vercube/queue'; +import { BullMQStrategy } from '@vercube/queue/strategies/BullMQStrategy'; + +export default defineConfig({ + plugins: [ + [QueuePlugin, { + strategies: [ + defineQueueStrategy({ + strategy: BullMQStrategy, + initOptions: { connection: { host: '127.0.0.1', port: 6379 } }, + }), + ], + }], + ], +}); +``` + +```ts [src/consumers/EmailConsumer.ts] +import { Inject } from '@vercube/di'; +import { Consumer, Job } from '@vercube/queue'; +import { MailerService } from '../services/MailerService'; + +@Consumer({ queue: 'emails', concurrency: 5 }) +export class EmailConsumer { + @Inject(MailerService) + private gMailer!: MailerService; + + @Job('welcome', { attempts: 3, backoff: { type: 'exponential', delay: 1000 }, timeout: 30_000 }) + public async welcome(payload: { userId: string }): Promise { + await this.gMailer.sendWelcome(payload.userId); + } +} +``` + +```ts [src/controllers/UsersController.ts] +@Post('/') +public async create(): Promise<{ ok: true }> { + const user = await this.gUsers.create(); + + await this.gQueue.add({ queue: 'emails', job: 'welcome', payload: { userId: user.id } }); + + return { ok: true }; +} +``` + +:: + +A consumer is an ordinary container service, so it starts consuming when you bind it, exactly like a controller that is never bound does nothing. Returning marks the job done, throwing marks the attempt failed. There is nothing to acknowledge by hand. + +Four transports ship with it and they can be mounted side by side: + +| Strategy | Backed by | Use it for | +| --- | --- | --- | +| `MemoryStrategy` | the running process | tests, examples, work that may be lost on restart | +| `BullMQStrategy` | Redis, through BullMQ | job queues with attempts, delays and priorities | +| `RabbitMQStrategy` | RabbitMQ, through amqplib | durable work distribution and dead lettering | +| `KafkaStrategy` | Kafka, through kafkajs | event streams, ordered per partition | + +The transports differ in what they can do natively, and the module fills in the rest rather than exposing the gap. BullMQ retries jobs itself; on RabbitMQ, Kafka and memory the manager republishes the job with a higher attempt counter after waiting out the backoff. `timeout` is enforced by the manager everywhere. A payload that fails its schema is failed immediately and never retried, because retrying a message that can never be parsed only burns attempts. Switching from `MemoryStrategy` to Kafka changes where jobs live, not how your code reads. + +Two smaller things that turned out to matter in practice. Queue and job names are plain strings by default, and augmenting `QueueTypes.Registry` type checks every `add()` against the payloads you declared, while unregistered queues keep working. And a web process that publishes without consuming is one option away: + +```ts +app.addPlugin(QueuePlugin, { autoStart: false, strategies: [/* … */] }); +``` + +Jobs are traced through the telemetry described further down, and because `add()` writes the active trace context into the job headers, a request that queues a job and a worker that runs it half a minute later in another process end up in **one trace**. Every attempt reuses the headers of the original publish, so a retry stays in the trace that queued the job instead of starting one of its own. + +Read more: **[Queue overview](/docs/modules/queue/overview)** · **[Consumers](/docs/modules/queue/consumers)** · **[Strategies](/docs/modules/queue/strategies)** · **[PR #1162](https://github.com/vercube/vercube/pull/1162)**. + + +## Devtools, rebuilt + +Devtools shipped in 1.2 with its own collectors. In 1.3 it has none. It registers an OpenTelemetry span processor, a metric reader and a log drain against the telemetry described further down, reads structural data from the introspection registry, and that is all it does. The wire format under the mount is OTLP, the same bytes a collector would receive. + +The consequence I like: instrumenting a package makes it visible in devtools for free. A storage read, a cache lookup, an authentication decision and your own `telemetry.span()` calls all show up nested in the request waterfall, without devtools knowing those packages exist. + +There is a new **Queues** panel: every mounted transport, every queue with a handler, the counters behind them, and on a transport that supports it, the messages waiting on a queue without consuming any of them. + + + + +Individual jobs are not listed there on purpose. They are traced, so a job appears in **Requests** as a consumer span parented on the request that queued it, however long ago and in whichever process that happened. + +The **Bootstrap** panel is gone, and nothing was lost with it. Container construction is now replayed as an ordinary trace, so startup appears in the request list as a `vercube.bootstrap` trace whose children are the services that were built. The same waterfall that explains a slow request explains a slow boot, and the constructor doing I/O at startup is still the typical finding. + +Two things changed shape for anyone scripting against the mount. The JSON API moved: structural sections are under `/_devtools/api/introspect/:section`, signals under `/_devtools/api/signals/:kind`. + +```bash +curl -s localhost:3000/_devtools/api/introspect/routes | jq '.data | length' +curl -s localhost:3000/_devtools/api/signals/traces | jq '[.resourceSpans[].scopeSpans[].spans[].name] | unique' +``` + +And enabling devtools now enables telemetry for you. Metrics are only collected while a browser is connected, buffers stay bounded, and devtools traffic is excluded from what devtools records, so the inspector never shows up in the data it is inspecting. + +Read more: **[Devtools overview](/docs/modules/devtools/overview)**. + + +## OpenTelemetry + +[`@vercube/telemetry`](https://www.npmjs.com/package/@vercube/telemetry) makes every request an OpenTelemetry span, records the standard HTTP metrics, and stamps every log line with the trace it belongs to. It speaks the OpenTelemetry API rather than a Vercube-specific one, so the backend is your choice: Jaeger, Tempo, Honeycomb, Datadog, or a collector you run yourself. + +```ts [vercube.config.ts] +import { defineConfig } from '@vercube/core'; +import { TelemetryPlugin } from '@vercube/telemetry'; + +export default defineConfig({ + telemetry: true, + plugins: [TelemetryPlugin], +}); +``` + +That much gives you W3C trace context propagation and log correlation. The OpenTelemetry API is a no-op until a provider exists, so point it somewhere in your entry file: + +```ts [src/index.ts] +import { startNodeTelemetry } from '@vercube/telemetry/sdk'; + +await startNodeTelemetry({ + serviceName: 'checkout', + endpoint: 'http://localhost:4318', +}); +``` + +Every request comes out as a `SERVER` span named after its route, carrying the stable HTTP semantic conventions plus the two attributes a framework can add that a proxy cannot: + +``` +GET /users/:id + http.request.method GET + http.route /users/:id + http.response.status_code 200 + url.path /users/42 + vercube.controller UsersController + vercube.handler byId +``` + +Alongside the spans: `http.server.request.duration` as a histogram keyed by method, route and status, and process gauges for heap, RSS, CPU utilisation and event loop delay. The URL is deliberately not a metric attribute, because one time series per URL is how a metrics backend gets destroyed. + +The part I care about most is that **instrumentation is not something you wire up per module**. `@vercube/storage` traces every operation, `@vercube/cache` traces lookups and counts hits and misses, `@vercube/ws` traces messages, `@vercube/auth` records each authentication decision, `@vercube/queue` traces published and processed jobs, and `@vercube/serverless` flushes before an invocation returns. They nest under the request that caused them with nothing to configure. Your own work joins them with one call: + +```ts +public refund(id: string) { + return this.gTelemetry.span('invoice.refund', (span) => { + span.setAttribute('invoice.id', id); + return this.process(id); + }); +} +``` + +`span()` returns whatever your function returns, unchanged, so wrapping synchronous code does not make it asynchronous. + +### Instrumenting a library of your own + +None of those packages depends on OpenTelemetry. They all go through `@vercube/telemetry/instrument`, and that subpath is public for the same reason they needed it: it touches neither the HTTP layer, nor the plugin, nor the DI container, so a library can instrument itself without pulling a framework in. + +```ts +import { createInstrument, SpanKind } from '@vercube/telemetry/instrument'; + +const instrument = createInstrument('acme-billing'); + +export function charge(amount: number): Promise { + return instrument.span('billing.charge', { kind: SpanKind.CLIENT }, () => gateway.charge(amount)); +} +``` + +`counter`, `upDownCounter` and `histogram` memoize by name, so calling them on every operation is the intended usage. `inject`, `extract` and `spanFrom` are there for the case that took the longest to get right: continuing a trace in another process. And every method is a no-op until an application registers a provider, which is what makes instrumenting unconditionally safe. + +This is also why `@opentelemetry/*` now appears in exactly one `package.json` in the whole repository. The honest cost of that: `@vercube/storage` gave up its framework-free claim and depends on `@vercube/telemetry` now. + +Two decisions are worth stating out loud. Telemetry is **on in development and off in production by default**, because tracing every request costs real throughput and a production deployment should turn it on together with an exporter and a sampler. And with `telemetry: false` the framework's instrumentation points are a single `null` check, so the fast path added in 1.2 still returns its response synchronously. + +Body and header capture exist, are off by default, and are only enabled automatically outside production. Credential-bearing headers, credential-looking query parameters and configuration values are withheld from spans whatever you configure. + +Read more: **[Telemetry overview](/docs/modules/telemetry/overview)** · **[Instrumentation](/docs/modules/telemetry/instrumentation)** · **[PR #1169](https://github.com/vercube/vercube/pull/1169)** · **[PR #1188](https://github.com/vercube/vercube/pull/1188)**. + + +## Introspection + +Telemetry answers what your application is *doing*. The new introspection registry in `@vercube/core` answers what it *is*: the route table, the merged configuration, the container bindings and their cycles, the registered plugins, the OpenAPI document, the files the build-time scanner found. + +```ts +const section = await this.gIntrospection.describe('routes'); + +return section!.data.filter((route) => + route.args.some((arg) => arg.type === 'body' && !arg.validated), +); +``` + +Sections are contributed by the packages that own the data, so `container` comes from `@vercube/di`, `openapi` from `@vercube/schema`, `discovery` from `@vercube/vite`, and your own plugin can register one in a few lines. Each provider reports a cheap `revision` that changes only when its data would come out different, which is what lets a dashboard show four panels without rebuilding the dependency graph four times, and lets devtools answer a reopened panel with a `304`. + +The same data is available without a browser, which is the part I did not expect to use as much as I do: + +```bash +$ vercube inspect --section routes | jq '[.routes.data[] | select(.args[]?.validated == false)] | length' +``` + +`vercube inspect` builds your application and runs its real entry file, stopping it right before it would bind a port. That means it sees the routes and bindings your `setup` actually produces, not a static guess, which makes it usable as a CI check against unvalidated bodies, accidental routes or a binding nothing resolves. + +Read more: **[Introspection](/docs/core-features/introspection)**. + + +## Logger + +Small, but it is what made the rest possible. `configure()` sets the whole logger configuration, which makes it the wrong tool for a package that only wants to observe. Three additive hooks now exist for that: `addDrain(name, fn)`, `addEnricher(name, fn)` and `addPlugin(plugin)`. Registrations survive a later `configure()`, and registering the same name twice replaces rather than stacks. + +```ts +container.get(Logger).addContextProvider(() => ({ region: process.env.FLY_REGION })); +``` + +`addContextProvider` covers the gap evlog's `enrich` hook leaves: `enrich` only runs for request wide events, so it cannot decorate a plain `logger.info()`. A context provider is consulted for every event and merged in first, so anything the call site passes still wins. This is how telemetry puts `traceId` and `spanId` on every log line. + +Read more: **[Logger drivers](/docs/modules/logger/drivers)**. + + +## Upgrade + +```bash +pnpm add @vercube/core@latest +pnpm add @vercube/telemetry +pnpm add @vercube/queue +``` + +`@vercube/telemetry` is the whole telemetry installation: the tracer and meter providers, the samplers and the in-memory test providers ship with it. Only exporting to an OTLP collector needs one more package, because the exporter pulls a protobuf stack that an application tracing locally has no use for: + +```bash +pnpm add @opentelemetry/exporter-trace-otlp-http +``` + +Nothing in this release breaks an application that does not use these packages. Telemetry stays off in production unless you ask for it, queues do nothing until the plugin is registered and a consumer is bound, and the introspection registry is read-only. + +If you script against devtools, the JSON routes changed as described above, and the Bootstrap panel is now a trace. If you call `logger.configure()` from a library, prefer the additive hooks so you stop clobbering the application's configuration. + +The full list of changes is in the [changelog](https://vercube.dev/changelog). + +--- + +I would like to hear which transport people actually reach for, and whether `vercube inspect` ends up in anyone's CI besides mine. Issues and discussions are on [GitHub](https://github.com/vercube/vercube), and I am on [X](https://x.com/OskarLebuda). From 8723fb56d9190926708b83528d523fece3452d85 Mon Sep 17 00:00:00 2001 From: Devol Date: Mon, 7 Sep 2026 21:25:53 +0200 Subject: [PATCH 2/4] feat: refresh the benchmark section with the 1.3 run Numbers come from results/results.md in vercube/benchmarks, a full run of the suite on one machine. Vercube now leads five of the six cells: first on all three workloads against the JS routers, and first on plain text and on a JSON body against the frameworks that also ship a container. Two changes to the shape as well. The rows are split into the two questions a reader actually has, "how does it compare to a bare router" and "how does it compare to what I would otherwise pick", because one list mixing both answered neither. And the "Fastest" badge is computed per group instead of being pinned to Vercube, which now matters: Rikta leads the container group on the query route. Both groups share one scale per metric, so the row Vercube appears in twice is the same width twice and the two groups can be read against each other. --- app/components/Home/Benchmarks.vue | 221 +++++++++++++++++++++-------- 1 file changed, 161 insertions(+), 60 deletions(-) diff --git a/app/components/Home/Benchmarks.vue b/app/components/Home/Benchmarks.vue index 7c2fbca..9afa3bc 100644 --- a/app/components/Home/Benchmarks.vue +++ b/app/components/Home/Benchmarks.vue @@ -6,7 +6,8 @@ Measurably faster

- Real numbers from the open benchmark suite. Same endpoints, comparable config, January 2026. + A container and decorators, at the speed of a bare router. Same routes, same machine, one run of the open benchmark + suite on Node, September 2026.

@@ -30,41 +31,59 @@ -
-
-
- - {{ row.name }} - - Fastest - - - - {{ format(row.value, activeMetric.unit) }} {{ activeMetric.unit }} +
+
+
+

+ {{ group.title }} +

+ + {{ group.caption }}
-
-
+ +
+
+
+ + {{ row.name }} + + Fastest + + + + {{ format(row.value, activeMetric.unit) }} {{ activeMetric.unit }} + +
+
+
+
+
-

+

+ {{ activeMetric.note }} +

+ +

Lower is better. - Run it yourself: + Node 24, 500 connections, 10s per route. Run it yourself: metrics[active.value]!); -const maxValue = computed(() => Math.max(...activeMetric.value.rows.map((row) => row.value))); +// One scale for both groups of a metric, so the row Vercube shares between them +// is the same width twice and the two groups can be read against each other. +const maxValue = computed(() => Math.max(...activeMetric.value.groups.flatMap((group) => group.rows.map((row) => row.value)))); + +// The badge marks whichever framework actually leads its group. Vercube leads +// one group on one tab, so hard-coding it here would be a lie everywhere else. +function leaderOf(group: BenchGroup): string | undefined { + const best = activeMetric.value.higherIsBetter + ? Math.max(...group.rows.map((row) => row.value)) + : Math.min(...group.rows.map((row) => row.value)); + + return group.rows.find((row) => row.value === best)?.name; +} function barWidth(value: number): number { // Bars represent the real value; min 6% so the smallest is still visible. From f966f3b280a4f60fd373c7fdbf9a700ab7f8f11e Mon Sep 17 00:00:00 2001 From: Devol Date: Mon, 7 Sep 2026 21:25:53 +0200 Subject: [PATCH 3/4] fix: tighten the hero's vertical rhythm The first screen ended in dead space: with min-h-[78vh] plus padding the code window started about a hundred pixels below the fold, so the page opened on a title card and nothing else. At 60vh its top edge lands inside the first screen and the fold crops it, which is what invites the scroll. Also: a denser scrim under the headline, because the display face is built from dots and so is the particle field behind it, so the two were competing; five staggered entrances collapsed into two beats, the title card then the file; and a focus-visible ring on the install command, which had none. Layout, type scale and copy are untouched. --- app/components/Home/Hero.vue | 38 ++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/app/components/Home/Hero.vue b/app/components/Home/Hero.vue index dda5b33..7493b24 100644 --- a/app/components/Home/Hero.vue +++ b/app/components/Home/Hero.vue @@ -16,26 +16,20 @@

-
-

+

+

// {{ page.hero.slug }}

-

+

{{ page.hero.title }}

-

+

{{ page.hero.description }}

-
+
-
+ +
@@ -100,7 +96,9 @@ async function copyInstall() {