feat(elysia): add isolated Elysia 2 integration - #12
Conversation
There was a problem hiding this comment.
10 findings, verified against the published elysia@2.0.0-beta.4 dist, details inline. The four in plugin-next.ts matter most (span leak on abort, misreported early returns, statusFrom() body/status confusion, throw status(4xx) logged as error); the rest are packaging/tooling issues.
|
Thanks for the detailed review and for verifying these cases against the published Elysia build. I’ll carefully go through all the findings and come back with a more robust and correct solution. I really appreciate the feedback |
|
Addressed all 10 review findings. Runtime:
Packaging and fixtures:
Validated with package tests (40), typecheck, build, Elysia v2 smoke tests (4), Express smoke tests, and pnpm test:smoke. |
Polliog
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround. I verified the new head with a fresh install, build, the full plugin-next unit suite and the v2 smoke tests against the real elysia 2.0.0-beta.4: 8 of the 10 fixes hold. Requesting changes for two new runtime issues found during verification (marked Blocking below). Everything else is non-blocking and fine as a follow-up.
| }); | ||
| } | ||
| }) | ||
| .afterResponse(({ request, responseValue, set }) => { |
There was a problem hiding this comment.
Blocking. Routes registered before .use(logtide()) leak one active span per successful request: the span-starting request hook is app-level, but finalization lives in the route-scoped afterResponse hook, which pre-registered routes never receive. Reproduced: new Elysia().get('/before', h).use(logtide(...)), then GET /before returns 200 with no traceparent and the span stays in activeSpans forever. Errors on such routes still finalize, so the leak is success-only and easy to miss. Fix via an app-level finalization path, or detect pre-registered routes and document plugin-first registration.
There was a problem hiding this comment.
Fixed in 852cdd4. Span creation and finalization now live in Elysia's wrap(), which wraps the final fetch for routes registered both before and after the plugin. The wrapper also adds traceparent to the final response. Tests cover a pre-plugin success route and a pre-plugin request-hook early response.
| return undefined; | ||
| } | ||
|
|
||
| function isExpectedClientStatus(error: unknown): boolean { |
There was a problem hiding this comment.
Blocking. isExpectedClientStatus only recognizes ElysiaStatus and Response, so Elysia's built-in 4xx error classes (NotFound, ParseError, ValidationError, HTTPError) are still captured as error logs and error spans. Reproduced: a GET to a nonexistent route produces an error log; even HEAD on a GET-only route does. Any URL scanner or validation failure (422) floods error tracking. Suggestion: treat any error carrying an integer status in 400..499 as expected.
There was a problem hiding this comment.
Fixed in 852cdd4. Expected client errors now include Response values and error objects with an integer status from 400 through 499. Tests cover NotFound, a route miss, validation 422, and an unsupported method. They keep the HTTP status on the span without creating an error log.
| } | ||
|
|
||
| return new Elysia({ name: '@logtide/elysia/next' }) | ||
| .request(({ request }) => { |
There was a problem hiding this comment.
WebSocket upgrades start a span that is never finalized at upgrade time: the WS branch in handler/fetch.mjs returns without running afterResponse, and an upgraded connection's request.signal never aborts. Push-only sockets leak the span forever; the first inbound message then finalizes it as a fabricated 200 with duration equal to time-to-first-message. Worth handling (skip or finalize at upgrade) before release, though I won't block on it.
There was a problem hiding this comment.
Fixed in 72dfdcd and 6f813d0. The wrapper recognizes GET requests with Upgrade: websocket; when Bun/Elysia upgrades and returns undefined, it finalizes the HTTP handshake span with status 101. A Bun 1.4 regression test opens a real socket without sending messages and verifies that GET /ws is already delivered as 101 in a separate CI step.
| "elysia": ">=1.0.0" | ||
| "elysia": ">=1.0.0 <2.0.0 || >=2.0.0-beta.4 <3.0.0-0" | ||
| }, | ||
| "devDependencies": { |
There was a problem hiding this comment.
typebox is a required non-optional peer of elysia@2.0.0-beta.4 but is missing here (and in test-apps/elysia-v2/package.json, which added exact-mirror but not typebox). It currently resolves only via pnpm auto-install-peers; under npm/yarn or with auto-install-peers disabled, elysia 2 type-system use throws Cannot find module 'typebox' at runtime. Please add it explicitly.
There was a problem hiding this comment.
Fixed in 852cdd4. typebox >=1.3.0 is now an explicit peer dependency, with typebox ^1.3.15 in the package development setup and smoke fixture.
| "dependencies": { | ||
| "@logtide/elysia": "workspace:*", | ||
| "@logtide/core": "workspace:*", | ||
| "elysia": "npm:elysia@next", |
There was a problem hiding this comment.
The smoke app still floats on npm:elysia@next while the package devDeps got pinned, so the pinning fix is incomplete: when next moves to beta.5 or a 3.0 alpha, any non-frozen install re-resolves the smoke suite (and its vitest alias) to a version the plugin was not built against. Pin the same 2.0.0-beta.4 here.
There was a problem hiding this comment.
Fixed in 852cdd4. The Elysia v2 smoke app is pinned to npm:elysia@2.0.0-beta.4, matching the plugin's tested prerelease.
| ctx.abortHandler = () => finalizeRequest(request, 499, true); | ||
|
|
||
| spanMap.set(request, ctx); | ||
| request.signal.addEventListener('abort', ctx.abortHandler, { once: true }); |
There was a problem hiding this comment.
Non-blocking perf note: touching request.signal on every request defeats Elysia's Bun lazy-signal optimization (signal materialization plus listener bookkeeping on the hot path). Arming the abort listener lazily would keep the fast lane intact.
There was a problem hiding this comment.
Agreed. I am keeping this as a performance follow-up. The current runtime fix retains eager abort-signal handling for correctness; lazy arming is outside this lifecycle change.
| request.signal.addEventListener('abort', ctx.abortHandler, { once: true }); | ||
| if (request.signal.aborted) ctx.abortHandler(); | ||
| }) | ||
| .afterHandle(({ request, set }) => { |
There was a problem hiding this comment.
The afterHandle hook is redundant: mapResponse runs on every mapped path (success, early return, error) and writes the identical traceparent header, so normal responses write it twice. Dropping afterHandle preserves behavior.
There was a problem hiding this comment.
Fixed in 852cdd4. afterHandle, mapResponse, and route-level afterResponse no longer participate in tracing. wrap() is now the single point for traceparent propagation and span completion.
|
|
||
| export interface LogtideElysiaOptions extends ClientOptions {} | ||
|
|
||
| function breadcrumbsToEvents(scope: Scope): SpanEvent[] { |
There was a problem hiding this comment.
breadcrumbsToEvents and the hub.init/integrations boilerplate are verbatim copies of plugin.ts (and createMockTransport is copied across test files). A shared internal module used by both plugins would prevent silent drift. Fine as a follow-up.
There was a problem hiding this comment.
Fixed in 0e22b9a. Elysia 1 and 2 now share the hub.init and integrations setup, breadcrumb conversion, and mock transport helpers.
| } | ||
|
|
||
| const scope = client.createScope(traceId); | ||
| const url = new URL(request.url); |
There was a problem hiding this comment.
finalizeRequest re-parses new URL(request.url) although the request hook already parsed it; carrying pathname/method in the span ctx would remove the second parse per request.
There was a problem hiding this comment.
Fixed in 852cdd4. ActiveSpan now stores the request method and pathname at creation time, and finalization reuses them without parsing the URL again.
| ); | ||
| } | ||
|
|
||
| function hasDevelopmentAlias(declaration) { |
There was a problem hiding this comment.
Each declaration file is parsed twice per build: patchDeclaration builds the AST, then hasDevelopmentAlias re-parses the patched text with the same visitor, which adds no safety (same blind spot). A substring scan would be cheaper and a stronger post-condition.
There was a problem hiding this comment.
Fixed in 852cdd4. The patcher now does one AST rewrite pass, then uses a raw substring post-check for elysia-next instead of parsing the declaration a second time.
|
Pushed five follow-up commits:
Local validation passed:
|
Polliog
left a comment
There was a problem hiding this comment.
Thanks for the thorough work on this, especially moving span lifecycle into wrap() rather than patching around the hook edges. Both blocking issues are addressed and CI is green, so I'm approving and merging the preview integration.
A follow-up review surfaced a few more items that we'll handle on our side rather than keep this open:
typeboxis declared as a hard peer dependency, so existing Elysia 1.x users (who depend on@sinclair/typebox, nottypebox) will see an unmet peer, and installs fail understrict-peer-dependencies. This needspeerDependenciesMeta.typebox.optionalbefore the next publish.expectedClientStatusmatches any thrown object with a 4xxstatusfield, so genuine application errors from HTTP/DB clients (Octokit, fetch wrappers) get dropped from telemetry. That was my suggestion originally and it was too broad; it needs an Elysia brand check.- Instrumentation now runs in the outermost
wrap()layer, so a throw inside span bookkeeping turns a successful response into a rejected request. Worth wrapping in its own try/catch. - README, CHANGELOG and the PR body each describe a different set of lifecycle hooks, none matching the current implementation (only
.error()and.wrap()are registered).
Merging now so the preview is available; we'll take these from here.
Summary
Adds an isolated Elysia 2 integration at
@logtide/elysia/nextwhile preserving the existing Elysia 1.x contract at@logtide/elysia.The build and test aliases are pinned to Elysia
^1.4.29and2.0.0-beta.4so both adapters compile and test against known framework versions without sharing framework types. Elysia 2 prereleases are supported as documented and tested version tuples, not as an open-ended promise for arbitrary future prereleases.Changes
@logtide/elysia/next: Elysia 2 entry point using therequest,mapResponse,error, andafterResponselifecycle APIs.elysia@^1.4.29, while the preview export builds against theelysia-nextdevelopment alias pinned to2.0.0-beta.4.traceparentand record their actual status. Primitive bodies do not affect HTTP status telemetry, and controlled Elysia 4xx wrappers do not create error telemetry..d.ts, and.d.ctstargets for./next. Published runtime and declarations reference the consumer peer packageelysia, never the development-only alias.test-app-elysia-v2covers typechecking, smoke tests, and consumer-peer resolution through the built@logtide/elysia/nextexport.Tests
pnpm buildpnpm typecheckpnpm testpnpm test:smoke@logtide/elysia: 40 package tests passtest-app-elysia-v2: typecheck and 4 smoke tests passelysiarather thanelysia-next.