Skip to content

feat(elysia): add isolated Elysia 2 integration - #12

Merged
Polliog merged 16 commits into
logtide-dev:mainfrom
pileskyd:feat/elysia-v2-export
Aug 22, 2026
Merged

feat(elysia): add isolated Elysia 2 integration#12
Polliog merged 16 commits into
logtide-dev:mainfrom
pileskyd:feat/elysia-v2-export

Conversation

@pileskyd

@pileskyd pileskyd commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an isolated Elysia 2 integration at @logtide/elysia/next while preserving the existing Elysia 1.x contract at @logtide/elysia.

The build and test aliases are pinned to Elysia ^1.4.29 and 2.0.0-beta.4 so 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 the request, mapResponse, error, and afterResponse lifecycle APIs.
  • Isolated contracts: the root export builds against elysia@^1.4.29, while the preview export builds against the elysia-next development alias pinned to 2.0.0-beta.4.
  • Response telemetry: spans finalize exactly once, including client aborts. Early request-hook responses propagate traceparent and record their actual status. Primitive bodies do not affect HTTP status telemetry, and controlled Elysia 4xx wrappers do not create error telemetry.
  • Packaging: includes ESM, CJS, .d.ts, and .d.cts targets for ./next. Published runtime and declarations reference the consumer peer package elysia, never the development-only alias.
  • Compatibility fixture: test-app-elysia-v2 covers typechecking, smoke tests, and consumer-peer resolution through the built @logtide/elysia/next export.
  • Documentation: updates the package README, root README, and changelog with version-specific imports, lifecycle contracts, and the Elysia prerelease policy.

Tests

  • pnpm build
  • pnpm typecheck
  • pnpm test
  • pnpm test:smoke
  • @logtide/elysia: 40 package tests pass
  • test-app-elysia-v2: typecheck and 4 smoke tests pass
  • Express smoke tests pass
  • Generated ESM/CJS runtime files and both declaration modes were checked to import elysia rather than elysia-next.

@Polliog Polliog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/elysia/src/plugin-next.ts Outdated
Comment thread packages/elysia/src/plugin-next.ts Outdated
Comment thread packages/elysia/src/plugin-next.ts Outdated
Comment thread packages/elysia/src/plugin-next.ts
Comment thread packages/elysia/package.json Outdated
Comment thread packages/elysia/package.json Outdated
Comment thread package.json Outdated
Comment thread test-apps/elysia-v2/vitest.config.ts
Comment thread packages/elysia/scripts/patch-next-declarations.mjs Outdated
Comment thread pnpm-lock.yaml Outdated
@pileskyd

Copy link
Copy Markdown
Contributor Author

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

@pileskyd

Copy link
Copy Markdown
Contributor Author

Addressed all 10 review findings.

Runtime:

  • abort-safe, exactly-once span finalization;
  • correct tracing/statuses for early request-hook responses;
  • no primitive-body status inference;
  • controlled Elysia 4xx responses no longer create error telemetry.

Packaging and fixtures:

  • restored Elysia 1.x peer compatibility and documented the Elysia 2 prerelease policy;
  • pinned build dependencies;
  • hardened declaration rewriting with AST-based tests;
  • removed the Vitest dedupe masking from the Elysia 2 fixture;
  • scoped the cookie override so Express 4 uses cookie 0.7.2 again.

Validated with package tests (40), typecheck, build, Elysia v2 smoke tests (4), Express smoke tests, and pnpm test:smoke.

@pileskyd
pileskyd requested a review from Polliog August 20, 2026 16:19

@Polliog Polliog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/elysia/src/plugin-next.ts Outdated
});
}
})
.afterResponse(({ request, responseValue, set }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/elysia/src/plugin-next.ts Outdated
return undefined;
}

function isExpectedClientStatus(error: unknown): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/elysia/src/plugin-next.ts Outdated
}

return new Elysia({ name: '@logtide/elysia/next' })
.request(({ request }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread test-apps/elysia-v2/package.json Outdated
"dependencies": {
"@logtide/elysia": "workspace:*",
"@logtide/core": "workspace:*",
"elysia": "npm:elysia@next",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 852cdd4. The Elysia v2 smoke app is pinned to npm:elysia@2.0.0-beta.4, matching the plugin's tested prerelease.

Comment thread packages/elysia/src/plugin-next.ts Outdated
ctx.abortHandler = () => finalizeRequest(request, 499, true);

spanMap.set(request, ctx);
request.signal.addEventListener('abort', ctx.abortHandler, { once: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/elysia/src/plugin-next.ts Outdated
request.signal.addEventListener('abort', ctx.abortHandler, { once: true });
if (request.signal.aborted) ctx.abortHandler();
})
.afterHandle(({ request, set }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/elysia/src/plugin-next.ts Outdated

export interface LogtideElysiaOptions extends ClientOptions {}

function breadcrumbsToEvents(scope: Scope): SpanEvent[] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0e22b9a. Elysia 1 and 2 now share the hub.init and integrations setup, breadcrumb conversion, and mock transport helpers.

Comment thread packages/elysia/src/plugin-next.ts Outdated
}

const scope = client.createScope(traceId);
const url = new URL(request.url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@pileskyd

Copy link
Copy Markdown
Contributor Author

Pushed five follow-up commits:

  • 852cdd4 moves Elysia v2 tracing to wrap(), fixes pre-plugin routes and built-in 4xx handling, pins the v2 dependencies, and hardens declaration rewriting.
  • 0e22b9a shares Elysia 1 and 2 internals and test helpers.
  • 4a45003 strengthens consumer packaging, declaration, typecheck, alias, and smoke-test coverage.
  • 72dfdcd adds WebSocket upgrade span coverage with status 101.
  • 6f813d0 typechecks the Bun WebSocket regression coverage.

Local validation passed:

  • pnpm --filter @logtide/elysia test: 51 tests
  • pnpm --filter @logtide/elysia typecheck
  • pnpm --filter @logtide/elysia build
  • pnpm --filter test-app-elysia-v2 typecheck
  • pnpm --filter test-app-elysia-v2 test: 4 tests

@pileskyd
pileskyd requested a review from Polliog August 22, 2026 08:28

@Polliog Polliog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • typebox is declared as a hard peer dependency, so existing Elysia 1.x users (who depend on @sinclair/typebox, not typebox) will see an unmet peer, and installs fail under strict-peer-dependencies. This needs peerDependenciesMeta.typebox.optional before the next publish.
  • expectedClientStatus matches any thrown object with a 4xx status field, 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.

@Polliog
Polliog merged commit a975b54 into logtide-dev:main Aug 22, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants