diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7863a23..90109b1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,6 +43,9 @@ jobs:
- name: Build all packages
run: pnpm build
+ - name: Validate published Elysia v2 declarations
+ run: pnpm --filter @logtide/elysia test:declarations
+
# ====================
# TypeScript Check
# ====================
@@ -129,6 +132,11 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.4.0
+
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -138,6 +146,9 @@ jobs:
- name: Run smoke tests (Hono, Elysia, Express, Fastify)
run: pnpm test:smoke
+ - name: Run Elysia v2 WebSocket regression test
+ run: pnpm --filter test-app-elysia-v2 test:websocket
+
# ====================
# E2E Tests (Playwright)
# ====================
diff --git a/.gitignore b/.gitignore
index 3c9a556..165bc9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,7 @@ coverage/
.cache/
.temp/
tmp/
+.bun-test/
# Framework build output
.next/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59a2685..c356c24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+
+- **`@logtide/elysia/next`** — an isolated Elysia 2.x integration using the new `request`, `afterHandle`, and `error` lifecycle contracts. The existing `@logtide/elysia` export remains on the Elysia 1.4 contract.
+
## [0.12.0] - 2026-08-06
### Added
diff --git a/README.md b/README.md
index cb1db06..260b5a2 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@
| [`@logtide/sveltekit`](./packages/sveltekit) | [](https://www.npmjs.com/package/@logtide/sveltekit) | SvelteKit hooks integration |
| [`@logtide/hono`](./packages/hono) | [](https://www.npmjs.com/package/@logtide/hono) | Hono middleware |
| [`@logtide/angular`](./packages/angular) | [](https://www.npmjs.com/package/@logtide/angular) | Angular ErrorHandler, HTTP Interceptor |
-| [`@logtide/elysia`](./packages/elysia) | [](https://www.npmjs.com/package/@logtide/elysia) | Elysia plugin |
+| [`@logtide/elysia`](./packages/elysia) | [](https://www.npmjs.com/package/@logtide/elysia) | Elysia 1.4 plugin + Elysia 2 preview via `/next` |
| [`@logtide/browser`](./packages/browser) | [](https://www.npmjs.com/package/@logtide/browser) | Browser SDK — Web Vitals, breadcrumbs, offline transport |
| [`@logtide/cli`](./packages/cli) | [](https://www.npmjs.com/package/@logtide/cli) | CLI — source maps upload and release management |
| [`@logtide/sdk-node`](./packages/node) | [](https://www.npmjs.com/package/@logtide/sdk-node) | Legacy Node.js SDK _(use `@logtide/express` or `@logtide/fastify` instead)_ |
@@ -101,7 +101,7 @@ npx @logtide/cli sourcemaps upload --api-url https://your-logtide-instance.com -
├── @logtide/sveltekit ← SvelteKit (handle/handleError/handleFetch)
├── @logtide/hono ← Hono middleware
├── @logtide/angular ← Angular (ErrorHandler + HttpInterceptor)
-├── @logtide/elysia ← Elysia plugin
+├── @logtide/elysia ← Elysia 1.4 plugin + Elysia 2 /next export
├── @logtide/browser ← Browser SDK (Web Vitals, breadcrumbs, offline)
├── @logtide/cli ← CLI (source maps upload)
└── @logtide/sdk-node ← Legacy standalone Node.js SDK
diff --git a/package.json b/package.json
index 4970ce3..c4aeacc 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"scripts": {
"build": "pnpm -r --filter @logtide/* build",
"test": "pnpm -r --filter @logtide/* test",
- "test:smoke": "pnpm -r --filter test-app-hono --filter test-app-elysia --filter test-app-node-express --filter test-app-node-fastify test",
+ "test:smoke": "pnpm -r --filter test-app-hono --filter test-app-elysia --filter test-app-elysia-v2 --filter test-app-node-express --filter test-app-node-fastify test",
"test:e2e": "pnpm -r --filter test-app-nextjs --filter test-app-sveltekit --filter test-app-nuxt --filter test-app-angular test",
"test:all": "pnpm test && pnpm test:smoke",
"typecheck": "pnpm -r typecheck",
@@ -19,7 +19,7 @@
"tar": ">=7.5.11",
"esbuild": ">=0.25.0",
"webpack": ">=5.104.1",
- "cookie": ">=0.7.0",
+ "cookie@<0.7.0": ">=0.7.0",
"minimatch": ">=10.2.3",
"rollup": ">=4.59.0",
"ajv": ">=8.18.0",
@@ -34,7 +34,7 @@
"simple-git": ">=3.32.3",
"@hono/node-server": ">=1.19.10",
"defu": ">=6.1.5",
- "elysia": ">=1.4.27",
+ "elysia@<2": ">=1.4.27 <2",
"h3": ">=1.15.9",
"immutable": ">=5.1.5",
"lodash": ">=4.18.1",
diff --git a/packages/elysia/README.md b/packages/elysia/README.md
index 0e45fab..fe55b75 100644
--- a/packages/elysia/README.md
+++ b/packages/elysia/README.md
@@ -7,7 +7,7 @@
-
+
@@ -19,8 +19,8 @@
## Features
-- **Automatic request spans** via `onRequest` / `onAfterHandle` lifecycle hooks
-- **Error capture** via `onError` hook with full request context
+- **Automatic request spans** via version-specific Elysia lifecycle hooks
+- **Error capture** with full request context
- **W3C Trace Context** propagation (`traceparent` in/out)
- **Breadcrumbs** for HTTP requests
- **Global plugin** — traces all routes automatically
@@ -41,6 +41,8 @@ bun add @logtide/elysia
## Quick Start
+Use the root export with Elysia 1.x:
+
```typescript
import { Elysia } from 'elysia';
import { logtide } from '@logtide/elysia';
@@ -58,17 +60,50 @@ const app = new Elysia()
.listen(3000);
```
+Use the isolated `next` export with Elysia 2.x:
+
+```typescript
+import { Elysia } from 'elysia';
+import { logtide } from '@logtide/elysia/next';
+
+const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_your_key@your-instance.com',
+ service: 'my-elysia-api',
+ environment: 'production',
+ }))
+ .get('/hello', () => 'Hello World')
+ .listen(3000);
+```
+
+Do not mix the root and `next` exports: they intentionally target the incompatible Elysia 1.4 and 2.x contracts respectively.
+
+## Version support
+
+| Elysia version | Import | Support |
+| --- | --- | --- |
+| 1.0 to 1.4 | `@logtide/elysia` | Supported |
+| 2.0.0 prereleases from `beta.4` | `@logtide/elysia/next` | Supported |
+| Stable 2.x | `@logtide/elysia/next` | Supported |
+| 3.x | — | Not supported |
+
+npm semver can only match a prerelease when a range names its exact `major.minor.patch` tuple. The `2.0.0-beta.4` range therefore accepts later `2.0.0` prereleases, but not a prerelease on a future 2.x patch or minor. We add support for a new prerelease line after validating it.
+
---
## How It Works
-The plugin registers global lifecycle hooks on the Elysia app:
+Both plugins register global lifecycle hooks on the Elysia app:
-1. **`onRequest`** — extracts incoming `traceparent`, creates a span, stores scope on a WeakMap keyed by the request
-2. **`onAfterHandle`** — finishes the span with `ok` or `error`, injects `traceparent` into response headers
-3. **`onError`** — finishes the span as `error`, captures the error with full HTTP context
+| Stage | Elysia 1.4 (`@logtide/elysia`) | Elysia 2 (`@logtide/elysia/next`) |
+|-------|---------------------------------|-------------------------------------|
+| Request | `onRequest` | `request` |
+| Response | `onAfterHandle` | `afterHandle` + `afterResponse` |
+| Error | `onError` | `error` |
-The plugin is registered with `.as('global')` so it applies to all routes, including those added after the plugin.
+The request hook extracts an incoming `traceparent` and creates a span. The response hooks inject `traceparent` and finish the span using the final mapped status. The error hook captures unexpected and 5xx failures with HTTP context; controlled 4xx responses remain normal request telemetry.
+
+Each plugin is registered with `.as('global')` so it applies to all routes, including those added after the plugin.
---
@@ -126,8 +161,13 @@ Outgoing responses include a `traceparent` header with the span's context.
## Exports
```typescript
+// Elysia 1.4
import { logtide } from '@logtide/elysia';
import type { LogtideElysiaOptions } from '@logtide/elysia';
+
+// Elysia 2.x
+import { logtide } from '@logtide/elysia/next';
+import type { LogtideElysiaOptions } from '@logtide/elysia/next';
```
---
diff --git a/packages/elysia/package.json b/packages/elysia/package.json
index 663a170..c954062 100644
--- a/packages/elysia/package.json
+++ b/packages/elysia/package.json
@@ -16,14 +16,25 @@
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
+ },
+ "./next": {
+ "import": {
+ "types": "./dist/next.d.ts",
+ "default": "./dist/next.js"
+ },
+ "require": {
+ "types": "./dist/next.d.cts",
+ "default": "./dist/next.cjs"
+ }
}
},
"files": [
"dist"
],
"scripts": {
- "build": "tsup",
+ "build": "tsup && node scripts/patch-next-declarations.mjs",
"test": "vitest run",
+ "test:declarations": "tsc --project tests/fixtures/next-declarations-consumer/tsconfig.json && node scripts/validate-next-declarations.mjs",
"typecheck": "tsc --noEmit",
"clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\""
},
@@ -46,13 +57,17 @@
"@logtide/types": "workspace:*"
},
"peerDependencies": {
- "elysia": ">=1.0.0"
+ "elysia": ">=1.0.0 <2.0.0 || >=2.0.0-beta.4 <3.0.0-0",
+ "typebox": ">=1.3.0"
},
"devDependencies": {
"@sinclair/typebox": "^0.34.48",
- "elysia": "^1.2.0",
+ "elysia": "npm:elysia@^1.4.29",
+ "elysia-next": "npm:elysia@2.0.0-beta.4",
+ "exact-mirror": "^1.2.4",
"file-type": "^22.0.0",
"tsup": "^8.5.1",
- "typescript": "^5.5.4"
+ "typebox": "^1.3.15",
+ "typescript": "^5.7.0"
}
}
diff --git a/packages/elysia/scripts/patch-next-declarations.mjs b/packages/elysia/scripts/patch-next-declarations.mjs
new file mode 100644
index 0000000..05ff5c0
--- /dev/null
+++ b/packages/elysia/scripts/patch-next-declarations.mjs
@@ -0,0 +1,82 @@
+import { readFile, writeFile } from 'node:fs/promises';
+import { pathToFileURL } from 'node:url';
+import ts from 'typescript';
+
+const declarationPaths = ['dist/next.d.ts', 'dist/next.d.cts'];
+const developmentAlias = 'elysia-next';
+const publishedSpecifier = 'elysia';
+
+function isDevelopmentAlias(specifier) {
+ return specifier === developmentAlias || specifier.startsWith(`${developmentAlias}/`);
+}
+
+function aliasReplacement(specifier) {
+ return specifier === developmentAlias
+ ? publishedSpecifier
+ : `${publishedSpecifier}${specifier.slice(developmentAlias.length)}`;
+}
+
+function aliasSpecifiers(sourceFile) {
+ const specifiers = [];
+
+ function addSpecifier(node) {
+ if (!node || !ts.isStringLiteral(node) || !isDevelopmentAlias(node.text)) return;
+
+ const start = node.getStart(sourceFile) + 1;
+ const end = node.getEnd() - 1;
+ specifiers.push({ start, end, replacement: aliasReplacement(node.text) });
+ }
+
+ function visit(node) {
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
+ addSpecifier(node.moduleSpecifier);
+ } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
+ addSpecifier(node.argument.literal);
+ } else if (ts.isExternalModuleReference(node)) {
+ addSpecifier(node.expression);
+ }
+
+ ts.forEachChild(node, visit);
+ }
+
+ ts.forEachChild(sourceFile, visit);
+ return specifiers;
+}
+
+function patchDeclaration(declaration) {
+ const sourceFile = ts.createSourceFile('next.d.ts', declaration, ts.ScriptTarget.Latest, false);
+ const replacements = aliasSpecifiers(sourceFile);
+
+ return replacements
+ .reverse()
+ .reduce(
+ (patched, { start, end, replacement }) =>
+ `${patched.slice(0, start)}${replacement}${patched.slice(end)}`,
+ declaration
+ );
+}
+
+export async function patchNextDeclarations(paths = declarationPaths) {
+ const patchedDeclarations = [];
+
+ for (const path of paths) {
+ const declaration = await readFile(path, 'utf8');
+ const patched = patchDeclaration(declaration);
+
+ if (new RegExp(`\\b${developmentAlias}\\b`).test(patched)) {
+ throw new Error(`Expected ${path} to contain no elysia-next development alias after patching`);
+ }
+
+ patchedDeclarations.push([path, patched, declaration]);
+ }
+
+ for (const [path, patched, declaration] of patchedDeclarations) {
+ if (patched !== declaration) {
+ await writeFile(path, patched);
+ }
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await patchNextDeclarations();
+}
diff --git a/packages/elysia/scripts/validate-next-declarations.mjs b/packages/elysia/scripts/validate-next-declarations.mjs
new file mode 100644
index 0000000..5da4c45
--- /dev/null
+++ b/packages/elysia/scripts/validate-next-declarations.mjs
@@ -0,0 +1,55 @@
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import ts from 'typescript';
+
+const fixtureConfigPath = 'tests/fixtures/next-declarations-consumer/tsconfig.json';
+const declarationPaths = ['dist/next.d.ts', 'dist/next.d.cts'];
+
+function readCompilerOptions(configPath) {
+ const config = ts.readConfigFile(configPath, ts.sys.readFile);
+ if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'));
+
+ return ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(configPath)).options;
+}
+
+function moduleSpecifiers(sourceFile) {
+ const specifiers = new Set();
+
+ function addSpecifier(node) {
+ if (node && ts.isStringLiteral(node)) specifiers.add(node.text);
+ }
+
+ function visit(node) {
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
+ addSpecifier(node.moduleSpecifier);
+ } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
+ addSpecifier(node.argument.literal);
+ } else if (ts.isExternalModuleReference(node)) {
+ addSpecifier(node.expression);
+ }
+
+ ts.forEachChild(node, visit);
+ }
+
+ ts.forEachChild(sourceFile, visit);
+ return specifiers;
+}
+
+export function validateNextDeclarations(paths = declarationPaths, fixtureConfig = fixtureConfigPath) {
+ const compilerOptions = readCompilerOptions(fixtureConfig);
+ const host = ts.sys;
+
+ for (const path of paths) {
+ const declaration = readFileSync(path, 'utf8');
+ const sourceFile = ts.createSourceFile(path, declaration, ts.ScriptTarget.Latest, false);
+
+ for (const specifier of moduleSpecifiers(sourceFile)) {
+ const resolved = ts.resolveModuleName(specifier, resolve(path), compilerOptions, host).resolvedModule;
+ if (!resolved) {
+ throw new Error(`Unable to resolve ${JSON.stringify(specifier)} from ${path}`);
+ }
+ }
+ }
+}
+
+validateNextDeclarations();
diff --git a/packages/elysia/src/next.ts b/packages/elysia/src/next.ts
new file mode 100644
index 0000000..728ffef
--- /dev/null
+++ b/packages/elysia/src/next.ts
@@ -0,0 +1 @@
+export { logtide, type LogtideElysiaOptions } from './plugin-next';
diff --git a/packages/elysia/src/plugin-next.ts b/packages/elysia/src/plugin-next.ts
new file mode 100644
index 0000000..9c17193
--- /dev/null
+++ b/packages/elysia/src/plugin-next.ts
@@ -0,0 +1,253 @@
+import type { Scope } from '@logtide/core';
+import { hub, generateTraceId, parseTraceparent, createTraceparent } from '@logtide/core';
+import Elysia from 'elysia-next';
+import {
+ breadcrumbsToEvents,
+ initializeLogtideElysia,
+ type LogtideElysiaOptions,
+} from './shared';
+
+export type { LogtideElysiaOptions } from './shared';
+
+function expectedClientStatus(error: unknown): number | undefined {
+ if (error instanceof Response) {
+ return error.status >= 400 && error.status < 500 ? error.status : undefined;
+ }
+
+ if (typeof error !== 'object' || error === null || !('status' in error)) {
+ return undefined;
+ }
+
+ const { status } = error as { status: unknown };
+ return typeof status === 'number' && Number.isInteger(status) && status >= 400 && status < 500
+ ? status
+ : undefined;
+}
+
+function isExpectedClientStatus(error: unknown): boolean {
+ return expectedClientStatus(error) !== undefined;
+}
+
+function isWebSocketUpgrade(request: Request): boolean {
+ return request.method === 'GET' && request.headers.get('upgrade')?.toLowerCase() === 'websocket';
+}
+
+function withTraceparent(response: Response, traceparent: string): Response {
+ try {
+ response.headers.set('traceparent', traceparent);
+ return response;
+ } catch {
+ if (response.status < 200 || response.status > 599) return response;
+
+ const headers = new Headers(response.headers);
+ headers.set('traceparent', traceparent);
+
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+ }
+}
+
+/**
+ * Elysia 2 plugin for LogTide — request tracing, error capture, breadcrumbs.
+ *
+ * @example
+ * ```ts
+ * import { Elysia } from 'elysia';
+ * import { logtide } from '@logtide/elysia/next';
+ *
+ * const app = new Elysia()
+ * .use(logtide({ dsn: '...', service: 'my-api' }))
+ * .get('/', () => 'Hello');
+ * ```
+ */
+export function logtide(options: LogtideElysiaOptions) {
+ initializeLogtideElysia(options);
+
+ type ActiveSpan = {
+ spanId: string;
+ scope: Scope;
+ traceId: string;
+ startTime: number;
+ method: string;
+ pathname: string;
+ finalized: boolean;
+ errored: boolean;
+ abortHandler?: () => void;
+ };
+
+ const spanMap = new WeakMap();
+
+ function finalizeRequest(request: Request, status: number, aborted = false) {
+ const ctx = spanMap.get(request);
+ if (!ctx || ctx.finalized) return;
+
+ ctx.finalized = true;
+ if (ctx.abortHandler) {
+ request.signal.removeEventListener('abort', ctx.abortHandler);
+ }
+
+ const client = hub.getClient();
+ if (!client) return;
+
+ const { scope, spanId, startTime, method, pathname } = ctx;
+ const durationMs = Date.now() - startTime;
+
+ scope.addBreadcrumb({
+ type: 'http',
+ category: 'response',
+ message: `${status} ${method} ${pathname}`,
+ level: aborted || ctx.errored || status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info',
+ timestamp: Date.now(),
+ data: { status, duration_ms: durationMs, ...(aborted ? { aborted: true } : {}) },
+ });
+
+ const events = breadcrumbsToEvents(scope);
+ const spanStatus = aborted || ctx.errored || status >= 500 ? 'error' : 'ok';
+
+ client.finishSpan(spanId, spanStatus, {
+ extraAttributes: {
+ 'http.status_code': status,
+ 'duration_ms': durationMs,
+ ...(aborted ? { 'http.request.aborted': true } : {}),
+ },
+ events,
+ });
+ }
+
+ function startRequest(request: Request) {
+ if (spanMap.has(request)) return;
+
+ const client = hub.getClient();
+ if (!client) return;
+
+ const traceparent = request.headers.get('traceparent');
+ let traceId: string;
+ let parentSpanId: string | undefined;
+
+ if (traceparent) {
+ const ctx = parseTraceparent(traceparent);
+ if (ctx) {
+ traceId = ctx.traceId;
+ parentSpanId = ctx.parentSpanId;
+ } else {
+ traceId = generateTraceId();
+ }
+ } else {
+ traceId = generateTraceId();
+ }
+
+ const scope = client.createScope(traceId);
+ const url = new URL(request.url);
+ const method = request.method;
+ const pathname = url.pathname;
+ const userAgent = request.headers.get('user-agent');
+ const forwardedFor = request.headers.get('x-forwarded-for');
+ const queryString = url.search;
+
+ const startAttributes: Record = {
+ 'http.method': method,
+ 'http.url': request.url,
+ 'http.target': pathname,
+ };
+ if (userAgent) startAttributes['http.user_agent'] = userAgent;
+ if (forwardedFor) startAttributes['net.peer.ip'] = forwardedFor;
+ if (queryString) startAttributes['http.query_string'] = queryString;
+
+ const span = client.startSpan({
+ name: `${method} ${pathname}`,
+ traceId,
+ parentSpanId,
+ attributes: startAttributes,
+ });
+ scope.spanId = span.spanId;
+
+ const startTime = Date.now();
+ scope.addBreadcrumb({
+ type: 'http',
+ category: 'request',
+ message: `${method} ${pathname}`,
+ timestamp: Date.now(),
+ data: {
+ method,
+ url: request.url,
+ ...(userAgent ? { userAgent } : {}),
+ },
+ });
+
+ const ctx: ActiveSpan = {
+ spanId: span.spanId,
+ scope,
+ traceId,
+ startTime,
+ method,
+ pathname,
+ finalized: false,
+ errored: false,
+ };
+ ctx.abortHandler = () => finalizeRequest(request, 499, true);
+
+ spanMap.set(request, ctx);
+ request.signal.addEventListener('abort', ctx.abortHandler, { once: true });
+ if (request.signal.aborted) ctx.abortHandler();
+ }
+
+ return new Elysia({ name: '@logtide/elysia/next' })
+ .error(({ request, error }) => {
+ const client = hub.getClient();
+ const ctx = spanMap.get(request);
+ if (!client || !ctx || ctx.finalized || isExpectedClientStatus(error)) return;
+
+ if (ctx) {
+ ctx.errored = true;
+ client.captureError(error, {
+ 'http.url': request.url,
+ 'http.method': request.method,
+ }, ctx.scope);
+ }
+ })
+ .wrap((next) => async (request, ...args) => {
+ startRequest(request);
+
+ try {
+ const response = await next(request, ...args) as Response | undefined;
+ const ctx = spanMap.get(request);
+
+ if (response === undefined) {
+ if (isWebSocketUpgrade(request)) {
+ finalizeRequest(request, 101);
+ } else if (ctx) {
+ ctx.errored = true;
+ finalizeRequest(request, 500);
+ }
+ return response as unknown as Response;
+ }
+
+ const tracedResponse = ctx
+ ? withTraceparent(response, createTraceparent(ctx.traceId, ctx.spanId, true))
+ : response;
+ finalizeRequest(request, response.status);
+ return tracedResponse;
+ } catch (error) {
+ const clientStatus = expectedClientStatus(error);
+ const ctx = spanMap.get(request);
+ if (clientStatus !== undefined) {
+ finalizeRequest(request, clientStatus);
+ throw error;
+ }
+
+ if (ctx && !ctx.finalized) {
+ ctx.errored = true;
+ hub.getClient()?.captureError(error, {
+ 'http.url': request.url,
+ 'http.method': request.method,
+ }, ctx.scope);
+ }
+ finalizeRequest(request, 500);
+ throw error;
+ }
+ })
+ .as('global');
+}
diff --git a/packages/elysia/src/plugin.ts b/packages/elysia/src/plugin.ts
index 67de443..252e89f 100644
--- a/packages/elysia/src/plugin.ts
+++ b/packages/elysia/src/plugin.ts
@@ -1,31 +1,13 @@
-import type { ClientOptions } from '@logtide/types';
-import type { Scope, SpanEvent } from '@logtide/core';
-import {
- hub,
- ConsoleIntegration,
- GlobalErrorIntegration,
- generateTraceId,
- parseTraceparent,
- createTraceparent,
-} from '@logtide/core';
+import type { Scope } from '@logtide/core';
+import { hub, generateTraceId, parseTraceparent, createTraceparent } from '@logtide/core';
import Elysia from 'elysia';
+import {
+ breadcrumbsToEvents,
+ initializeLogtideElysia,
+ type LogtideElysiaOptions,
+} from './shared';
-export interface LogtideElysiaOptions extends ClientOptions {}
-
-function breadcrumbsToEvents(scope: Scope): SpanEvent[] {
- return scope.getBreadcrumbs().map((b) => ({
- name: b.message,
- timestamp: b.timestamp,
- attributes: {
- 'breadcrumb.type': b.type,
- ...(b.category ? { 'breadcrumb.category': b.category } : {}),
- ...(b.level ? { 'breadcrumb.level': b.level } : {}),
- ...Object.fromEntries(
- Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)])
- ),
- },
- }));
-}
+export type { LogtideElysiaOptions } from './shared';
/**
* Elysia plugin for LogTide — request tracing, error capture, breadcrumbs.
@@ -41,15 +23,7 @@ function breadcrumbsToEvents(scope: Scope): SpanEvent[] {
* ```
*/
export function logtide(options: LogtideElysiaOptions) {
- hub.init({
- service: 'elysia',
- ...options,
- integrations: [
- new ConsoleIntegration(),
- new GlobalErrorIntegration(),
- ...(options.integrations ?? []),
- ],
- });
+ initializeLogtideElysia(options);
const spanMap = new WeakMap();
@@ -149,7 +123,7 @@ export function logtide(options: LogtideElysiaOptions) {
});
// Convert breadcrumbs to SpanEvents
- const events: SpanEvent[] = breadcrumbsToEvents(scope);
+ const events = breadcrumbsToEvents(scope);
const extraAttributes: Record = {
'http.status_code': status,
@@ -191,7 +165,7 @@ export function logtide(options: LogtideElysiaOptions) {
});
// Convert breadcrumbs to SpanEvents
- const events: SpanEvent[] = breadcrumbsToEvents(scope);
+ const events = breadcrumbsToEvents(scope);
client.finishSpan(spanId, 'error', {
extraAttributes: {
diff --git a/packages/elysia/src/shared.ts b/packages/elysia/src/shared.ts
new file mode 100644
index 0000000..78b4ed3
--- /dev/null
+++ b/packages/elysia/src/shared.ts
@@ -0,0 +1,32 @@
+import type { ClientOptions } from '@logtide/types';
+import type { Scope, SpanEvent } from '@logtide/core';
+import { hub, ConsoleIntegration, GlobalErrorIntegration } from '@logtide/core';
+
+export interface LogtideElysiaOptions extends ClientOptions {}
+
+export function initializeLogtideElysia(options: LogtideElysiaOptions) {
+ hub.init({
+ service: 'elysia',
+ ...options,
+ integrations: [
+ new ConsoleIntegration(),
+ new GlobalErrorIntegration(),
+ ...(options.integrations ?? []),
+ ],
+ });
+}
+
+export function breadcrumbsToEvents(scope: Scope): SpanEvent[] {
+ return scope.getBreadcrumbs().map((breadcrumb) => ({
+ name: breadcrumb.message,
+ timestamp: breadcrumb.timestamp,
+ attributes: {
+ 'breadcrumb.type': breadcrumb.type,
+ ...(breadcrumb.category ? { 'breadcrumb.category': breadcrumb.category } : {}),
+ ...(breadcrumb.level ? { 'breadcrumb.level': breadcrumb.level } : {}),
+ ...Object.fromEntries(
+ Object.entries(breadcrumb.data ?? {}).map(([key, value]) => [`data.${key}`, String(value)])
+ ),
+ },
+ }));
+}
diff --git a/packages/elysia/tests/fixtures/next-declarations-consumer/index.ts b/packages/elysia/tests/fixtures/next-declarations-consumer/index.ts
new file mode 100644
index 0000000..a90050a
--- /dev/null
+++ b/packages/elysia/tests/fixtures/next-declarations-consumer/index.ts
@@ -0,0 +1,8 @@
+import { logtide, type LogtideElysiaOptions } from '../../../dist/next.js';
+
+const options: LogtideElysiaOptions = {
+ dsn: 'https://example-key@example.com',
+ service: 'declaration-consumer',
+};
+
+logtide(options);
diff --git a/packages/elysia/tests/fixtures/next-declarations-consumer/tsconfig.json b/packages/elysia/tests/fixtures/next-declarations-consumer/tsconfig.json
new file mode 100644
index 0000000..0971ea1
--- /dev/null
+++ b/packages/elysia/tests/fixtures/next-declarations-consumer/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "elysia": ["../../../node_modules/elysia-next"],
+ "elysia/types": ["../../../node_modules/elysia-next/dist/types.d.ts"],
+ "elysia/*": ["../../../node_modules/elysia-next/*"]
+ }
+ },
+ "include": ["index.ts"]
+}
diff --git a/packages/elysia/tests/helpers.ts b/packages/elysia/tests/helpers.ts
new file mode 100644
index 0000000..f15403b
--- /dev/null
+++ b/packages/elysia/tests/helpers.ts
@@ -0,0 +1,11 @@
+import type { InternalLogEntry, Span } from '@logtide/types';
+
+export function createMockTransport() {
+ return {
+ logs: [] as InternalLogEntry[],
+ spans: [] as Span[],
+ async sendLogs(logs: InternalLogEntry[]) { this.logs.push(...logs); },
+ async sendSpans(spans: Span[]) { this.spans.push(...spans); },
+ async flush() {},
+ };
+}
diff --git a/packages/elysia/tests/patch-next-declarations.test.ts b/packages/elysia/tests/patch-next-declarations.test.ts
new file mode 100644
index 0000000..6e63605
--- /dev/null
+++ b/packages/elysia/tests/patch-next-declarations.test.ts
@@ -0,0 +1,44 @@
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { patchNextDeclarations } from '../scripts/patch-next-declarations.mjs';
+
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true })));
+});
+
+describe('patchNextDeclarations', () => {
+ it('rewrites declaration import specifiers and permits files without the alias', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'logtide-elysia-declarations-'));
+ temporaryDirectories.push(directory);
+ const esmPath = join(directory, 'next.d.ts');
+ const cjsPath = join(directory, 'next.d.cts');
+
+ await writeFile(esmPath, "import type { DefaultEphemeral } from 'elysia-next/types';\nimport Elysia from 'elysia-next';\nimport ElysiaCompat = require('elysia-next');\nexport { type Elysia } from 'elysia-next';\ntype ImportedElysia = import('elysia-next').Elysia;\ntype UnrelatedElysia = import('elysia-nextfoo').Elysia;\n");
+ await writeFile(cjsPath, "export interface LogtideElysiaOptions { enabled: boolean }\n");
+
+ await patchNextDeclarations([esmPath, cjsPath]);
+
+ await expect(readFile(esmPath, 'utf8')).resolves.toBe(
+ "import type { DefaultEphemeral } from 'elysia/types';\nimport Elysia from 'elysia';\nimport ElysiaCompat = require('elysia');\nexport { type Elysia } from 'elysia';\ntype ImportedElysia = import('elysia').Elysia;\ntype UnrelatedElysia = import('elysia-nextfoo').Elysia;\n"
+ );
+ await expect(readFile(cjsPath, 'utf8')).resolves.toBe(
+ "export interface LogtideElysiaOptions { enabled: boolean }\n"
+ );
+ });
+
+ it('rejects declaration output that still mentions the development alias', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'logtide-elysia-declarations-'));
+ temporaryDirectories.push(directory);
+ const esmPath = join(directory, 'next.d.ts');
+
+ await writeFile(esmPath, "// elysia-next must not be published\n");
+
+ await expect(patchNextDeclarations([esmPath])).rejects.toThrow(
+ /contain no elysia-next development alias after patching/
+ );
+ });
+});
diff --git a/packages/elysia/tests/plugin-next.test.ts b/packages/elysia/tests/plugin-next.test.ts
new file mode 100644
index 0000000..ca45c14
--- /dev/null
+++ b/packages/elysia/tests/plugin-next.test.ts
@@ -0,0 +1,725 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import Elysia, { NotFound, status, t } from 'elysia-next';
+import { logtide } from '../src/plugin-next';
+import { createMockTransport } from './helpers';
+
+describe('@logtide/elysia/next plugin', () => {
+ let transport: ReturnType;
+ let hub: typeof import('@logtide/core').hub;
+
+ beforeEach(async () => {
+ const core = await import('@logtide/core');
+ hub = core.hub;
+ await hub.close();
+ transport = createMockTransport();
+ });
+
+ afterEach(async () => {
+ await hub.close();
+ });
+
+ it('should create spans for requests', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/hello', () => 'world');
+
+ const res = await app.handle(new Request('http://localhost/hello'));
+ expect(res.status).toBe(200);
+ expect(await res.text()).toBe('world');
+
+ // Elysia should have recorded a span
+ expect(transport.spans.length).toBeGreaterThanOrEqual(1);
+ const span = transport.spans.find(s => s.name.includes('/hello'));
+ expect(span).toBeDefined();
+ expect(span!.status).toBe('ok');
+ });
+
+ it('should finalize spans for routes registered before the plugin', async () => {
+ const app = new Elysia()
+ .get('/before-plugin', () => 'ok')
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }));
+
+ const response = await app.handle(new Request('http://localhost/before-plugin'));
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('traceparent')).toMatch(
+ /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/
+ );
+ const spans = transport.spans.filter((span) => span.name.includes('/before-plugin'));
+ expect(spans).toHaveLength(1);
+ expect(spans[0].status).toBe('ok');
+ expect(spans[0].attributes['http.status_code']).toBe(200);
+ });
+
+ it('should trace early responses from request hooks registered before the plugin', async () => {
+ const app = new Elysia()
+ .request(() => status(202, 'accepted'))
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }));
+
+ const response = await app.handle(new Request('http://localhost/pre-plugin-early'));
+
+ expect(response.status).toBe(202);
+ expect(response.headers.get('traceparent')).toMatch(
+ /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/
+ );
+ const spans = transport.spans.filter((span) => span.name.includes('/pre-plugin-early'));
+ expect(spans).toHaveLength(1);
+ expect(spans[0].attributes['http.status_code']).toBe(202);
+ });
+
+ it('should extract incoming traceparent', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/traced', () => 'ok');
+
+ const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
+ const res = await app.handle(new Request('http://localhost/traced', {
+ headers: {
+ traceparent: `00-${traceId}-00f067aa0ba902b7-01`,
+ },
+ }));
+
+ expect(res.status).toBe(200);
+ const span = transport.spans.find(s => s.name.includes('/traced'));
+ expect(span?.traceId).toBe(traceId);
+ });
+
+ it('should trace an early response from a later request hook', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .request(() => status(429, 'rate limited'))
+ .get('/limited', () => 'ok');
+
+ const response = await app.handle(new Request('http://localhost/limited'));
+
+ expect(response.status).toBe(429);
+ expect(response.headers.get('traceparent')).toMatch(
+ /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/
+ );
+ const span = transport.spans.find(s => s.name.includes('/limited'));
+ expect(span?.attributes['http.status_code']).toBe(429);
+ });
+
+ it('should capture errors from handlers', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/error', () => {
+ throw new Error('elysia boom');
+ });
+
+ const res = await app.handle(new Request('http://localhost/error'));
+ // Elysia returns 500 for errors
+ expect(res.status).toBe(500);
+
+ // Should have captured the error
+ expect(transport.logs.length).toBeGreaterThanOrEqual(1);
+ const errLog = transport.logs.find(l => l.level === 'error');
+ expect(errLog).toBeDefined();
+ });
+
+ // ─── Richer traces tests ─────────────────────────────────────────────────────
+
+ it('should set http.status_code attribute on span for 200', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/status-200', () => 'ok');
+
+ await app.handle(new Request('http://localhost/status-200'));
+
+ const span = transport.spans.find(s => s.name.includes('/status-200'));
+ expect(span).toBeDefined();
+ expect(span!.attributes['http.status_code']).toBe(200);
+ });
+
+ it('should read status() response wrappers from the Elysia 2 contract', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/status-wrapper', () => status(404, 'missing'));
+
+ const response = await app.handle(new Request('http://localhost/status-wrapper'));
+
+ expect(response.status).toBe(404);
+ const span = transport.spans.find(s => s.name.includes('/status-wrapper'));
+ expect(span?.attributes['http.status_code']).toBe(404);
+ });
+
+ it('should read status from a Web Response', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/web-response', () => new Response('accepted', { status: 202 }));
+
+ const response = await app.handle(new Request('http://localhost/web-response'));
+
+ expect(response.status).toBe(202);
+ const span = transport.spans.find(s => s.name.includes('/web-response'));
+ expect(span?.attributes['http.status_code']).toBe(202);
+ });
+
+ it.each([
+ ['number body', 503],
+ ['string body', 'Unauthorized'],
+ ['prototype-named string body', 'toString'],
+ ])('should not treat a $s as an HTTP status', async (_name, body) => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/status-payload', () => body);
+
+ const response = await app.handle(new Request('http://localhost/status-payload'));
+
+ expect(response.status).toBe(200);
+ const span = transport.spans.find(s => s.name.includes('/status-payload'));
+ expect(span?.attributes['http.status_code']).toBe(200);
+ expect(span?.status).toBe('ok');
+ });
+
+ it.each([
+ ['return', () => status(401, 'unauthorized')],
+ ['throw', () => { throw status(401, 'unauthorized'); }],
+ ])('should not report $s status(401) as an error', async (_mode, handler) => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/controlled-401', handler);
+
+ const response = await app.handle(new Request('http://localhost/controlled-401'));
+
+ expect(response.status).toBe(401);
+ expect(transport.logs.some(log => log.level === 'error')).toBe(false);
+ const span = transport.spans.find(s => s.name.includes('/controlled-401'));
+ expect(span?.attributes['http.status_code']).toBe(401);
+ expect(span?.status).toBe('ok');
+ });
+
+ it('should report a thrown status(503) as an error', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/controlled-503', () => { throw status(503, 'unavailable'); });
+
+ const response = await app.handle(new Request('http://localhost/controlled-503'));
+
+ expect(response.status).toBe(503);
+ expect(transport.logs.some(log => log.level === 'error')).toBe(true);
+ const span = transport.spans.find(s => s.name.includes('/controlled-503'));
+ expect(span?.attributes['http.status_code']).toBe(503);
+ expect(span?.status).toBe('error');
+ });
+
+ it('should let set.status override a default Web Response status', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/response-override', ({ set }) => {
+ set.status = 'Created';
+ return new Response('created');
+ });
+
+ const response = await app.handle(new Request('http://localhost/response-override'));
+
+ expect(response.status).toBe(201);
+ const span = transport.spans.find(s => s.name.includes('/response-override'));
+ expect(span?.attributes['http.status_code']).toBe(201);
+ });
+
+ it('should finalize from the response produced by later afterHandle hooks', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .afterHandle(() => status(418, 'teapot'))
+ .get('/later-after-handle', () => 'ok');
+
+ const response = await app.handle(new Request('http://localhost/later-after-handle'));
+
+ expect(response.status).toBe(418);
+ const span = transport.spans.find(s => s.name.includes('/later-after-handle'));
+ expect(span?.attributes['http.status_code']).toBe(418);
+ });
+
+ it('should normalize named set.status values', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/named-status', ({ set }) => {
+ set.status = 'Created';
+ return 'created';
+ });
+
+ const response = await app.handle(new Request('http://localhost/named-status'));
+
+ expect(response.status).toBe(201);
+ const span = transport.spans.find(s => s.name.includes('/named-status'));
+ expect(span?.attributes['http.status_code']).toBe(201);
+ });
+
+ it('should not report Elysia 2 NotFound errors', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/not-found-error', () => {
+ throw new NotFound();
+ });
+
+ const response = await app.handle(new Request('http://localhost/not-found-error'));
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get('traceparent')).toMatch(
+ /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/
+ );
+ expect(transport.logs.some(log => log.level === 'error')).toBe(false);
+ const spans = transport.spans.filter(s => s.name.includes('/not-found-error'));
+ expect(spans).toHaveLength(1);
+ expect(spans[0].attributes['http.status_code']).toBe(404);
+ expect(spans[0].status).toBe('ok');
+ });
+
+ it('should not report unmatched routes as errors', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }));
+
+ const response = await app.handle(new Request('http://localhost/missing'));
+
+ expect(response.status).toBe(404);
+ expect(transport.logs.some(log => log.level === 'error')).toBe(false);
+ const span = transport.spans.find(s => s.name.includes('/missing'));
+ expect(span?.attributes['http.status_code']).toBe(404);
+ expect(span?.status).toBe('ok');
+ });
+
+ it('should not report validation errors as errors', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .post('/validated', { body: t.Object({ id: t.Number() }) }, () => 'ok');
+
+ const response = await app.handle(new Request('http://localhost/validated', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ id: 'not-a-number' }),
+ }));
+
+ expect(response.status).toBe(422);
+ expect(transport.logs.some(log => log.level === 'error')).toBe(false);
+ const span = transport.spans.find(s => s.name.includes('/validated'));
+ expect(span?.attributes['http.status_code']).toBe(422);
+ expect(span?.status).toBe('ok');
+ });
+
+ it('should not report unsupported methods as errors', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/get-only', () => 'ok');
+
+ const response = await app.handle(new Request('http://localhost/get-only', { method: 'POST' }));
+
+ expect(response.status).toBe(404);
+ expect(transport.logs.some(log => log.level === 'error')).toBe(false);
+ const span = transport.spans.find(s => s.name.includes('/get-only'));
+ expect(span?.attributes['http.status_code']).toBe(404);
+ expect(span?.status).toBe('ok');
+ });
+
+ it('should preserve immutable error responses', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/response-error', () => Response.error());
+
+ await expect(app.handle(new Request('http://localhost/response-error'))).resolves.toBeInstanceOf(Response);
+ });
+
+ it('should add traceparent to immutable redirect responses', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/redirect', () => Response.redirect('http://localhost/destination', 302));
+
+ const response = await app.handle(new Request('http://localhost/redirect'));
+
+ expect(response.status).toBe(302);
+ expect(response.headers.get('location')).toBe('http://localhost/destination');
+ expect(response.headers.get('traceparent')).toMatch(
+ /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/
+ );
+ });
+
+ it('should capture errors thrown by later wrappers', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .wrap((next) => async (request, ...args) => {
+ await next(request, ...args);
+ throw new Error('wrapper failure');
+ })
+ .get('/wrapper-error', () => 'ok');
+
+ await expect(app.handle(new Request('http://localhost/wrapper-error'))).rejects.toThrow('wrapper failure');
+
+ expect(transport.logs.some(log => log.level === 'error')).toBe(true);
+ const span = transport.spans.find(s => s.name.includes('/wrapper-error'));
+ expect(span?.status).toBe('error');
+ });
+
+ it('should not report expected client errors thrown by later wrappers', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .wrap((next) => async (request, ...args) => {
+ await next(request, ...args);
+ throw status(401, 'unauthorized');
+ })
+ .get('/wrapper-401', () => 'ok');
+
+ await expect(app.handle(new Request('http://localhost/wrapper-401'))).rejects.toBeDefined();
+
+ expect(transport.logs.some(log => log.level === 'error')).toBe(false);
+ const span = transport.spans.find(s => s.name.includes('/wrapper-401'));
+ expect(span?.attributes['http.status_code']).toBe(401);
+ expect(span?.status).toBe('ok');
+ });
+
+ it('should capture errors thrown by later afterHandle hooks', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .afterHandle(() => {
+ throw new Error('late failure');
+ })
+ .get('/late-error', () => 'ok');
+
+ const response = await app.handle(new Request('http://localhost/late-error'));
+
+ expect(response.status).toBe(500);
+ expect(response.headers.get('traceparent')).toMatch(
+ /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/
+ );
+ const spans = transport.spans.filter(s => s.name.includes('/late-error'));
+ expect(spans).toHaveLength(1);
+ expect(spans[0].attributes['http.status_code']).toBe(500);
+ expect(spans[0].status).toBe('error');
+ });
+
+ it('should keep error spans when a later error hook maps the response', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .error(() => status(418, 'handled'))
+ .get('/mapped-error', () => {
+ throw new Error('mapped failure');
+ });
+
+ const response = await app.handle(new Request('http://localhost/mapped-error'));
+
+ expect(response.status).toBe(418);
+ expect(response.headers.get('traceparent')).toMatch(
+ /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/
+ );
+ const spans = transport.spans.filter(s => s.name.includes('/mapped-error'));
+ expect(spans).toHaveLength(1);
+ expect(spans[0].attributes['http.status_code']).toBe(418);
+ expect(spans[0].status).toBe('error');
+ });
+
+ it('should inject traceparent into the response', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/response-trace', () => 'ok');
+
+ const response = await app.handle(new Request('http://localhost/response-trace'));
+
+ expect(response.headers.get('traceparent')).toMatch(
+ /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/
+ );
+ });
+
+ it('should set http.user_agent when User-Agent header is provided', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/ua', () => 'ok');
+
+ await app.handle(new Request('http://localhost/ua', {
+ headers: { 'user-agent': 'TestAgent/1.0' },
+ }));
+
+ const span = transport.spans.find(s => s.name.includes('/ua'));
+ expect(span).toBeDefined();
+ expect(span!.attributes['http.user_agent']).toBe('TestAgent/1.0');
+ });
+
+ it('should set duration_ms in span attributes', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/duration', () => 'ok');
+
+ await app.handle(new Request('http://localhost/duration'));
+
+ const span = transport.spans.find(s => s.name.includes('/duration'));
+ expect(span).toBeDefined();
+ expect(span!.attributes['duration_ms']).toBeGreaterThanOrEqual(0);
+ expect(typeof span!.attributes['duration_ms']).toBe('number');
+ });
+
+ it('should include breadcrumbs as span events (at least request + response)', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/events', () => 'ok');
+
+ await app.handle(new Request('http://localhost/events'));
+
+ const span = transport.spans.find(s => s.name.includes('/events'));
+ expect(span).toBeDefined();
+ expect(span!.events).toBeDefined();
+ expect(span!.events!.length).toBeGreaterThanOrEqual(2);
+
+ // Should have a request event
+ const requestEvent = span!.events!.find(e => e.name.includes('GET /events'));
+ expect(requestEvent).toBeDefined();
+
+ // Should also have a response event
+ const responseEvent = span!.events!.find(e => e.name.match(/^\d{3} GET \/events$/));
+ expect(responseEvent).toBeDefined();
+ });
+
+ it('should set http.query_string on span when query params are present', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/search', () => 'ok');
+
+ await app.handle(new Request('http://localhost/search?q=hello&page=1'));
+
+ const span = transport.spans.find(s => s.name.includes('/search'));
+ expect(span).toBeDefined();
+ expect(span!.attributes['http.query_string']).toBe('?q=hello&page=1');
+ });
+
+ it('should set net.peer.ip from x-forwarded-for header', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/ip', () => 'ok');
+
+ await app.handle(new Request('http://localhost/ip', {
+ headers: { 'x-forwarded-for': '1.2.3.4' },
+ }));
+
+ const span = transport.spans.find(s => s.name.includes('/ip'));
+ expect(span).toBeDefined();
+ expect(span!.attributes['net.peer.ip']).toBe('1.2.3.4');
+ });
+
+ it('should include request breadcrumb data field with method and url', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/data-check', () => 'ok');
+
+ await app.handle(new Request('http://localhost/data-check'));
+
+ const span = transport.spans.find(s => s.name.includes('/data-check'));
+ expect(span).toBeDefined();
+ expect(span!.events).toBeDefined();
+ const requestEvent = span!.events!.find(e => e.name.includes('GET /data-check'));
+ expect(requestEvent).toBeDefined();
+ expect(requestEvent!.attributes?.['data.method']).toBe('GET');
+ expect(requestEvent!.attributes?.['data.url']).toBeDefined();
+ });
+
+ it('should mark span as error on unhandled exceptions', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/boom', () => {
+ throw new Error('unexpected');
+ });
+
+ const res = await app.handle(new Request('http://localhost/boom'));
+ expect(res.status).toBe(500);
+
+ const span = transport.spans.find(s => s.name.includes('/boom'));
+ expect(span).toBeDefined();
+ expect(span!.status).toBe('error');
+ });
+
+ it('should not report abort errors after the span has finished', async () => {
+ const controller = new AbortController();
+ let handlerStarted!: () => void;
+ const started = new Promise((resolve) => {
+ handlerStarted = resolve;
+ });
+
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/abort-error', async ({ request }) => {
+ handlerStarted();
+ await new Promise((resolve) => {
+ request.signal.addEventListener('abort', () => resolve(), { once: true });
+ });
+ throw new Error('request aborted');
+ });
+
+ const responsePromise = app.handle(new Request('http://localhost/abort-error', {
+ signal: controller.signal,
+ }));
+ await started;
+ controller.abort();
+ await responsePromise;
+
+ expect(transport.logs.some(log => log.level === 'error')).toBe(false);
+ const span = transport.spans.find((entry) => entry.name.includes('/abort-error'));
+ expect(span?.attributes['http.status_code']).toBe(499);
+ expect(span?.status).toBe('error');
+ });
+
+ it('should finish exactly one span when the client aborts', async () => {
+ const controller = new AbortController();
+ let handlerStarted!: () => void;
+ const started = new Promise((resolve) => {
+ handlerStarted = resolve;
+ });
+
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/abort', async ({ request }) => {
+ handlerStarted();
+ await new Promise((resolve) => {
+ request.signal.addEventListener('abort', () => resolve(), { once: true });
+ });
+ return 'unreachable';
+ });
+
+ const responsePromise = app.handle(new Request('http://localhost/abort', {
+ signal: controller.signal,
+ }));
+ await started;
+ controller.abort();
+ await responsePromise;
+
+ const spans = transport.spans.filter((span) => span.name.includes('/abort'));
+ expect(spans).toHaveLength(1);
+ expect(spans[0].status).toBe('error');
+ expect(spans[0].attributes['http.request.aborted']).toBe(true);
+ });
+});
diff --git a/packages/elysia/tests/plugin.test.ts b/packages/elysia/tests/plugin.test.ts
index 5d73d39..c981213 100644
--- a/packages/elysia/tests/plugin.test.ts
+++ b/packages/elysia/tests/plugin.test.ts
@@ -1,17 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Elysia from 'elysia';
import { logtide } from '../src/plugin';
-import type { InternalLogEntry, Span } from '@logtide/types';
-
-function createMockTransport() {
- return {
- logs: [] as InternalLogEntry[],
- spans: [] as Span[],
- async sendLogs(logs: InternalLogEntry[]) { this.logs.push(...logs); },
- async sendSpans(spans: Span[]) { this.spans.push(...spans); },
- async flush() {},
- };
-}
+import { createMockTransport } from './helpers';
describe('@logtide/elysia plugin', () => {
let transport: ReturnType;
diff --git a/packages/elysia/tsup.config.ts b/packages/elysia/tsup.config.ts
index 7c09de8..d9f1a0e 100644
--- a/packages/elysia/tsup.config.ts
+++ b/packages/elysia/tsup.config.ts
@@ -1,10 +1,21 @@
import { defineConfig } from 'tsup';
export default defineConfig({
- entry: ['src/index.ts'],
+ entry: ['src/index.ts', 'src/next.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
external: ['@logtide/core', '@logtide/types', 'elysia'],
+ esbuildPlugins: [
+ {
+ name: 'elysia-next-runtime-alias',
+ setup(build) {
+ build.onResolve({ filter: /^elysia-next$/ }, () => ({
+ path: 'elysia',
+ external: true,
+ }));
+ },
+ },
+ ],
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1691989..9c86a2b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,7 +8,7 @@ overrides:
tar: '>=7.5.11'
esbuild: '>=0.25.0'
webpack: '>=5.104.1'
- cookie: '>=0.7.0'
+ cookie@<0.7.0: '>=0.7.0'
minimatch: '>=10.2.3'
rollup: '>=4.59.0'
ajv: '>=8.18.0'
@@ -23,7 +23,7 @@ overrides:
simple-git: '>=3.32.3'
'@hono/node-server': '>=1.19.10'
defu: '>=6.1.5'
- elysia: '>=1.4.27'
+ elysia@<2: '>=1.4.27 <2'
h3: '>=1.15.9'
immutable: '>=5.1.5'
lodash: '>=4.18.1'
@@ -154,17 +154,26 @@ importers:
specifier: ^0.34.48
version: 0.34.48
elysia:
- specifier: '>=1.4.27'
- version: 1.4.28(@sinclair/typebox@0.34.48)(exact-mirror@0.2.6(@sinclair/typebox@0.34.48))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.9.3)
+ specifier: npm:elysia@^1.4.29
+ version: 1.4.29(@sinclair/typebox@0.34.48)(@types/bun@1.4.0)(exact-mirror@1.2.4(typebox@1.3.15))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.8.3)
+ elysia-next:
+ specifier: npm:elysia@2.0.0-beta.4
+ version: elysia@2.0.0-beta.4(@types/bun@1.4.0)(exact-mirror@1.2.4(typebox@1.3.15))(openapi-types@12.1.3)(typebox@1.3.15)(typescript@5.8.3)
+ exact-mirror:
+ specifier: ^1.2.4
+ version: 1.2.4(typebox@1.3.15)
file-type:
specifier: '>=21.3.2'
version: 22.0.0
tsup:
specifier: ^8.5.1
- version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
+ version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.3)
+ typebox:
+ specifier: ^1.3.15
+ version: 1.3.15
typescript:
- specifier: ^5.5.4
- version: 5.9.3
+ specifier: ^5.7.0
+ version: 5.8.3
packages/express:
dependencies:
@@ -395,8 +404,8 @@ importers:
specifier: workspace:*
version: link:../../packages/elysia
elysia:
- specifier: '>=1.4.27'
- version: 1.4.28(@sinclair/typebox@0.34.48)(exact-mirror@0.2.6(@sinclair/typebox@0.34.48))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.9.3)
+ specifier: '>=1.4.27 <2'
+ version: 1.4.29(@sinclair/typebox@0.34.48)(@types/bun@1.4.0)(exact-mirror@1.2.4(typebox@1.3.15))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.9.3)
devDependencies:
logtide-mock-server:
specifier: workspace:*
@@ -408,6 +417,37 @@ importers:
specifier: ^3.0.0
version: 3.2.4(@types/node@20.19.32)(jiti@2.6.1)(jsdom@28.1.0)(less@4.2.2)(lightningcss@1.32.0)(sass@1.85.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
+ test-apps/elysia-v2:
+ dependencies:
+ '@logtide/core':
+ specifier: workspace:*
+ version: link:../../packages/core
+ '@logtide/elysia':
+ specifier: workspace:*
+ version: link:../../packages/elysia
+ elysia:
+ specifier: npm:elysia@2.0.0-beta.4
+ version: 2.0.0-beta.4(@types/bun@1.4.0)(exact-mirror@1.2.4(typebox@1.3.15))(openapi-types@12.1.3)(typebox@1.3.15)(typescript@5.8.3)
+ exact-mirror:
+ specifier: ^1.2.4
+ version: 1.2.4(typebox@1.3.15)
+ typebox:
+ specifier: ^1.3.15
+ version: 1.3.15
+ devDependencies:
+ '@types/bun':
+ specifier: ^1.4.0
+ version: 1.4.0
+ logtide-mock-server:
+ specifier: workspace:*
+ version: link:../mock-server
+ typescript:
+ specifier: ^5.7.0
+ version: 5.8.3
+ vitest:
+ specifier: ^3.0.0
+ version: 3.2.4(@types/node@20.19.32)(jiti@2.6.1)(jsdom@28.1.0)(less@4.2.2)(lightningcss@1.32.0)(sass@1.85.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
+
test-apps/hono:
dependencies:
'@hono/node-server':
@@ -3285,6 +3325,9 @@ packages:
'@types/bonjour@3.5.13':
resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==}
+ '@types/bun@1.4.0':
+ resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -3864,6 +3907,9 @@ packages:
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
+ bun-types@1.4.0:
+ resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==}
+
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
@@ -4327,6 +4373,9 @@ packages:
detect-node@2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
+ deuri@3.0.0:
+ resolution: {integrity: sha512-3rbQr245VesQGHyzUlWmpv3WihImbLT5NBe/mrgsgEvc7aWTYEpDhER8Z3RsdiDSYw1kxQo0kU9fpociUu77/w==}
+
devalue@5.7.0:
resolution: {integrity: sha512-qCvc8m7cImp1QDCsiY+C2EdSBWSj7Ucfoq87scSdYboDiIKdvMtFbH1U2VReBls6WMhMaUOoK3ZJEDNG/7zm3w==}
@@ -4379,8 +4428,8 @@ packages:
electron-to-chromium@1.5.286:
resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
- elysia@1.4.28:
- resolution: {integrity: sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg==}
+ elysia@1.4.29:
+ resolution: {integrity: sha512-GwMRGGwSdjfPt+w3LA0fqTuYJtS8uVRJicvoar98/HrO5qdFKDc9CwjIb6Kja+v39lkY+58hr2JvdR9jQzlUuA==}
peerDependencies:
'@sinclair/typebox': '>= 0.34.0 < 1'
'@types/bun': '>= 1.2.0'
@@ -4394,6 +4443,22 @@ packages:
typescript:
optional: true
+ elysia@2.0.0-beta.4:
+ resolution: {integrity: sha512-pFjjP+uANO9kMLvN3QKQphCH5atwOTCC/lMhvj+fDhV3BWiwGTDNDyZPMpu0nmtcUIgjrlVyDajhuzMkYoBUGg==}
+ peerDependencies:
+ '@types/bun': '>= 1.3.0'
+ exact-mirror: '>= 1.2.2'
+ openapi-types: '>= 12.0.0'
+ typebox: '>= 1.3.0'
+ typescript: '>= 5.7.0'
+ peerDependenciesMeta:
+ '@types/bun':
+ optional: true
+ openapi-types:
+ optional: true
+ typescript:
+ optional: true
+
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -4546,12 +4611,12 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
- exact-mirror@0.2.6:
- resolution: {integrity: sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA==}
+ exact-mirror@1.2.4:
+ resolution: {integrity: sha512-lNLw824fkSqcHSxNIh8skA7gHO5X5Q8UUllXuRedtJCU8cJwKOT3XPQY8GfhveatFMO4nCK8+oEXsLGErMWC/Q==}
peerDependencies:
- '@sinclair/typebox': ^0.34.15
+ typebox: '>= 1.1.0'
peerDependenciesMeta:
- '@sinclair/typebox':
+ typebox:
optional: true
execa@8.0.1:
@@ -5486,6 +5551,9 @@ packages:
memoirist@0.4.0:
resolution: {integrity: sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg==}
+ memoirist@1.2.2:
+ resolution: {integrity: sha512-d5b1l7vth7Iim1z4z+GHQtxs2sQvcsvDn928FdJW7dPFu3ChIb66xpaA/QF1VKKEiskScuB6RXramHpXF35ZEw==}
+
merge-descriptors@1.0.3:
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
@@ -7161,6 +7229,9 @@ packages:
type-level-regexp@0.1.17:
resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==}
+ typebox@1.3.15:
+ resolution: {integrity: sha512-gOKAjLqUr+bFGbO5vxCEO5+nHh2wO+swzSIkiJJC0kJ4oLN6f65SrZn6tJYhsYO+qdnpf2NmlQwgsayYoi1NkQ==}
+
typed-assert@1.0.9:
resolution: {integrity: sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==}
@@ -7253,6 +7324,7 @@ packages:
unplugin-vue-router@0.19.2:
resolution: {integrity: sha512-u5dgLBarxE5cyDK/hzJGfpCTLIAyiTXGlo85COuD4Nssj6G7NxS+i9mhCWz/1p/ud1eMwdcUbTXehQe41jYZUA==}
+ deprecated: 'Merged into vuejs/router. Migrate: https://router.vuejs.org/guide/migration/v4-to-v5.html'
peerDependencies:
'@vue/compiler-sfc': ^3.5.17
vue-router: ^4.6.0
@@ -7355,6 +7427,7 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validate-npm-package-license@3.0.4:
@@ -10659,6 +10732,10 @@ snapshots:
dependencies:
'@types/node': 20.19.32
+ '@types/bun@1.4.0':
+ dependencies:
+ bun-types: 1.4.0
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -11402,6 +11479,10 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
+ bun-types@1.4.0:
+ dependencies:
+ '@types/node': 20.19.32
+
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
@@ -11846,6 +11927,8 @@ snapshots:
detect-node@2.1.0: {}
+ deuri@3.0.0: {}
+
devalue@5.7.0: {}
diff@8.0.3: {}
@@ -11894,18 +11977,43 @@ snapshots:
electron-to-chromium@1.5.286: {}
- elysia@1.4.28(@sinclair/typebox@0.34.48)(exact-mirror@0.2.6(@sinclair/typebox@0.34.48))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.9.3):
+ elysia@1.4.29(@sinclair/typebox@0.34.48)(@types/bun@1.4.0)(exact-mirror@1.2.4(typebox@1.3.15))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.8.3):
+ dependencies:
+ '@sinclair/typebox': 0.34.48
+ cookie: 1.1.1
+ exact-mirror: 1.2.4(typebox@1.3.15)
+ fast-decode-uri-component: 1.0.1
+ file-type: 22.0.0
+ memoirist: 0.4.0
+ openapi-types: 12.1.3
+ optionalDependencies:
+ '@types/bun': 1.4.0
+ typescript: 5.8.3
+
+ elysia@1.4.29(@sinclair/typebox@0.34.48)(@types/bun@1.4.0)(exact-mirror@1.2.4(typebox@1.3.15))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.9.3):
dependencies:
'@sinclair/typebox': 0.34.48
cookie: 1.1.1
- exact-mirror: 0.2.6(@sinclair/typebox@0.34.48)
+ exact-mirror: 1.2.4(typebox@1.3.15)
fast-decode-uri-component: 1.0.1
file-type: 22.0.0
memoirist: 0.4.0
openapi-types: 12.1.3
optionalDependencies:
+ '@types/bun': 1.4.0
typescript: 5.9.3
+ elysia@2.0.0-beta.4(@types/bun@1.4.0)(exact-mirror@1.2.4(typebox@1.3.15))(openapi-types@12.1.3)(typebox@1.3.15)(typescript@5.8.3):
+ dependencies:
+ deuri: 3.0.0
+ exact-mirror: 1.2.4(typebox@1.3.15)
+ memoirist: 1.2.2
+ typebox: 1.3.15
+ optionalDependencies:
+ '@types/bun': 1.4.0
+ openapi-types: 12.1.3
+ typescript: 5.8.3
+
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -12043,9 +12151,9 @@ snapshots:
events@3.3.0: {}
- exact-mirror@0.2.6(@sinclair/typebox@0.34.48):
+ exact-mirror@1.2.4(typebox@1.3.15):
optionalDependencies:
- '@sinclair/typebox': 0.34.48
+ typebox: 1.3.15
execa@8.0.1:
dependencies:
@@ -13099,6 +13207,8 @@ snapshots:
memoirist@0.4.0: {}
+ memoirist@1.2.2: {}
+
merge-descriptors@1.0.3: {}
merge-descriptors@2.0.0: {}
@@ -15237,6 +15347,8 @@ snapshots:
type-level-regexp@0.1.17: {}
+ typebox@1.3.15: {}
+
typed-assert@1.0.9: {}
typescript@5.8.3: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 38d7f30..a0c624e 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -5,6 +5,7 @@ packages:
- "test-apps/node-fastify"
- "test-apps/hono"
- "test-apps/elysia"
+ - "test-apps/elysia-v2"
- "test-apps/nextjs"
- "test-apps/nuxt"
- "test-apps/sveltekit"
diff --git a/test-apps/elysia-v2/package.json b/test-apps/elysia-v2/package.json
new file mode 100644
index 0000000..e42aa03
--- /dev/null
+++ b/test-apps/elysia-v2/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "test-app-elysia-v2",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "test": "vitest run",
+ "test:websocket": "bun build ../../packages/elysia/dist/next.js --outdir .bun-test --external elysia --external @logtide/core --external @logtide/types && tsc --project tsconfig.bun.json && bun test ./tests/websocket.bun.ts",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@logtide/core": "workspace:*",
+ "@logtide/elysia": "workspace:*",
+ "elysia": "npm:elysia@2.0.0-beta.4",
+ "exact-mirror": "^1.2.4",
+ "typebox": "^1.3.15"
+ },
+ "devDependencies": {
+ "@types/bun": "^1.4.0",
+ "logtide-mock-server": "workspace:*",
+ "typescript": "^5.7.0",
+ "vitest": "^3.0.0"
+ }
+}
diff --git a/test-apps/elysia-v2/src/app.ts b/test-apps/elysia-v2/src/app.ts
new file mode 100644
index 0000000..918f3f1
--- /dev/null
+++ b/test-apps/elysia-v2/src/app.ts
@@ -0,0 +1,23 @@
+import { Elysia } from 'elysia';
+import { logtide } from '@logtide/elysia/next';
+import { hub } from '@logtide/core';
+
+export function createApp(dsn: string) {
+ return new Elysia()
+ .use(logtide({
+ dsn,
+ service: 'test-elysia-v2',
+ environment: 'test',
+ batchSize: 1,
+ flushInterval: 500,
+ }))
+ .get('/test-log', () => {
+ const client = hub.getClient();
+ client?.captureLog('info', 'manual log from elysia v2', { route: '/test-log' });
+ return { ok: true };
+ })
+ .get('/test-error', () => {
+ throw new Error('Test error from Elysia 2');
+ })
+ .get('/health', () => ({ status: 'ok' }));
+}
diff --git a/test-apps/elysia-v2/tests/smoke.test.ts b/test-apps/elysia-v2/tests/smoke.test.ts
new file mode 100644
index 0000000..bea03e6
--- /dev/null
+++ b/test-apps/elysia-v2/tests/smoke.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
+import { createMockServer, waitForLogs, waitForSpans, buildMockDSN } from 'logtide-mock-server/test-utils';
+import { hub } from '@logtide/core';
+import Elysia from 'elysia';
+import { createApp } from '../src/app.js';
+import { logtide } from '@logtide/elysia/next';
+
+describe('Elysia 2 + @logtide/elysia/next smoke test', () => {
+ let mockServer: ReturnType;
+ let mockUrl: string;
+ let app: ReturnType;
+
+ beforeAll(async () => {
+ mockServer = createMockServer();
+ const mock = await mockServer.start();
+ mockUrl = mock.url;
+ app = createApp(buildMockDSN(mockUrl));
+ });
+
+ afterAll(async () => {
+ await hub.close();
+ await mockServer.stop();
+ });
+
+ beforeEach(() => {
+ mockServer.reset();
+ });
+
+ it('should resolve the app Elysia 2 peer for the built next export', () => {
+ const plugin = logtide({
+ dsn: buildMockDSN(mockUrl),
+ service: 'test-elysia-v2-peer-resolution',
+ });
+
+ expect(typeof Elysia.prototype.request).toBe('function');
+ expect(plugin).toBeInstanceOf(Elysia);
+
+ // logtide() initializes the global hub, so restore the app's test client for later cases.
+ app = createApp(buildMockDSN(mockUrl));
+ });
+
+ it('should send logs on incoming HTTP requests', async () => {
+ const response = await app.handle(new Request('http://localhost/test-log'));
+ expect(response.status).toBe(200);
+
+ const logs = await waitForLogs(mockUrl, 1);
+ const log = logs.find((entry) => entry.message === 'manual log from elysia v2');
+ expect(log).toBeDefined();
+ expect(log!.service).toBe('test-elysia-v2');
+ expect(log!.metadata?.route).toBe('/test-log');
+ });
+
+ it('should capture errors with trace context', async () => {
+ const response = await app.handle(new Request('http://localhost/test-error'));
+ expect(response.status).toBe(500);
+
+ const logs = await waitForLogs(mockUrl, 1);
+ const errorLog = logs.find((entry) => entry.level === 'error');
+ expect(errorLog).toBeDefined();
+ expect(errorLog!.service).toBe('test-elysia-v2');
+ expect(errorLog!.trace_id).toMatch(/^[0-9a-f]{32}$/);
+ });
+
+ it('should send request spans', async () => {
+ await app.handle(new Request('http://localhost/test-log'));
+
+ const spans = await waitForSpans(mockUrl, 1);
+ const span = spans.find((entry) => entry.name?.includes('GET /test-log'));
+ expect(span).toBeDefined();
+ expect(span!.serviceName).toBe('test-elysia-v2');
+ expect(span!.status?.code).toBe(1);
+ });
+});
diff --git a/test-apps/elysia-v2/tests/websocket.bun.ts b/test-apps/elysia-v2/tests/websocket.bun.ts
new file mode 100644
index 0000000..6fad451
--- /dev/null
+++ b/test-apps/elysia-v2/tests/websocket.bun.ts
@@ -0,0 +1,57 @@
+import { afterAll, beforeAll, expect, test } from 'bun:test';
+import { Elysia } from 'elysia';
+import { websocket } from 'elysia/websocket';
+// The test command creates this consumer-resolved runtime bundle before typechecking.
+// @ts-expect-error Bun's generated bundle intentionally has no declaration file.
+import { logtide } from '../.bun-test/next.js';
+import { hub } from '@logtide/core';
+import { buildMockDSN, createMockServer, waitForSpans } from 'logtide-mock-server/test-utils';
+
+let mockServer: ReturnType;
+let mockUrl: string;
+let app: { server?: { port?: number }; stop(): unknown };
+
+afterAll(async () => {
+ await hub.close();
+ app.stop();
+ await mockServer.stop();
+});
+
+beforeAll(async () => {
+ mockServer = createMockServer();
+ const mock = await mockServer.start();
+ mockUrl = mock.url;
+
+ app = new Elysia()
+ .use(logtide({
+ dsn: buildMockDSN(mockUrl),
+ service: 'test-elysia-v2-websocket',
+ batchSize: 1,
+ flushInterval: 500,
+ }))
+ .use(websocket())
+ .ws('/ws', {
+ open() {},
+ })
+ .listen(0);
+});
+
+test('finishes the WebSocket upgrade as an HTTP 101 span before any message', async () => {
+ const port = app.server?.port;
+ expect(port).toBeNumber();
+
+ const socket = await new Promise((resolve, reject) => {
+ const connection = new WebSocket(`ws://127.0.0.1:${port}/ws`);
+ connection.addEventListener('open', () => resolve(connection), { once: true });
+ connection.addEventListener('error', () => reject(new Error('WebSocket connection failed')), { once: true });
+ });
+
+ const spans = await waitForSpans(mockUrl, 1);
+ const span = spans.find((entry) => entry.name === 'GET /ws');
+
+ expect(span).toBeDefined();
+ expect(span?.status?.code).toBe(1);
+ expect(span?.attributes).toContainEqual({ key: 'http.status_code', value: { intValue: '101' } });
+
+ socket.close();
+});
diff --git a/test-apps/elysia-v2/tsconfig.bun.json b/test-apps/elysia-v2/tsconfig.bun.json
new file mode 100644
index 0000000..235df51
--- /dev/null
+++ b/test-apps/elysia-v2/tsconfig.bun.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": ["bun"]
+ },
+ "include": ["tests/websocket.bun.ts"],
+ "exclude": []
+}
diff --git a/test-apps/elysia-v2/tsconfig.json b/test-apps/elysia-v2/tsconfig.json
new file mode 100644
index 0000000..d8dff65
--- /dev/null
+++ b/test-apps/elysia-v2/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "."
+ },
+ "include": ["src/**/*", "tests/**/*", "vitest.config.ts"],
+ "exclude": ["tests/**/*.bun.ts"]
+}
diff --git a/test-apps/elysia-v2/vitest.config.ts b/test-apps/elysia-v2/vitest.config.ts
new file mode 100644
index 0000000..c9ade22
--- /dev/null
+++ b/test-apps/elysia-v2/vitest.config.ts
@@ -0,0 +1,22 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vitest/config';
+
+const elysiaPeer = fileURLToPath(import.meta.resolve('elysia'));
+
+export default defineConfig({
+ resolve: {
+ // A published package resolves its bare `elysia` import from the consuming app's peer.
+ // The workspace symlink exposes @logtide/elysia's Elysia 1 devDependency instead, so
+ // model consumer resolution explicitly rather than relying on Vite's global dedupe.
+ alias: [
+ // Match only the bare runtime import. Elysia subpath imports retain their own exports.
+ { find: /^elysia$/, replacement: elysiaPeer },
+ ],
+ },
+ test: {
+ globals: false,
+ environment: 'node',
+ include: ['tests/**/*.test.ts'],
+ testTimeout: 30_000,
+ },
+});
diff --git a/test-apps/mock-server/src/test-utils.ts b/test-apps/mock-server/src/test-utils.ts
index d84d057..c91d745 100644
--- a/test-apps/mock-server/src/test-utils.ts
+++ b/test-apps/mock-server/src/test-utils.ts
@@ -15,7 +15,7 @@ export async function waitForLogs(
while (Date.now() < deadline) {
const res = await fetch(`${mockUrl}/test/logs`);
- const data = await res.json();
+ const data = await res.json() as { logs: ReceivedLog[] };
if (data.logs.length >= expectedCount) {
return data.logs;
}
@@ -23,7 +23,7 @@ export async function waitForLogs(
}
const res = await fetch(`${mockUrl}/test/logs`);
- const data = await res.json();
+ const data = await res.json() as { logs: ReceivedLog[] };
if (data.logs.length >= expectedCount) {
return data.logs;
}
@@ -45,7 +45,7 @@ export async function waitForSpans(
while (Date.now() < deadline) {
const res = await fetch(`${mockUrl}/test/spans`);
- const data = await res.json();
+ const data = await res.json() as { spans: ReceivedSpan[] };
if (data.spans.length >= expectedCount) {
return data.spans;
}
@@ -53,7 +53,7 @@ export async function waitForSpans(
}
const res = await fetch(`${mockUrl}/test/spans`);
- const data = await res.json();
+ const data = await res.json() as { spans: ReceivedSpan[] };
if (data.spans.length >= expectedCount) {
return data.spans;
}