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..725ffa1 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",
@@ -34,7 +34,7 @@
"simple-git": ">=3.32.3",
"@hono/node-server": ">=1.19.10",
"defu": ">=6.1.5",
- "elysia": ">=1.4.27",
+ "elysia@<1.4.27": "1.4.29",
"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..183e0ac 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.4:
+
```typescript
import { Elysia } from 'elysia';
import { logtide } from '@logtide/elysia';
@@ -58,17 +60,39 @@ 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.
+
---
## 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 marks the span as failed and captures the exception with HTTP context.
+
+Each plugin is registered with `.as('global')` so it applies to all routes, including those added after the plugin.
---
@@ -126,8 +150,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..c2becec 100644
--- a/packages/elysia/package.json
+++ b/packages/elysia/package.json
@@ -16,13 +16,23 @@
"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",
"typecheck": "tsc --noEmit",
"clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\""
@@ -46,13 +56,15 @@
"@logtide/types": "workspace:*"
},
"peerDependencies": {
- "elysia": ">=1.0.0"
+ "elysia": ">=1.4.0 <2.0.0 || >=2.0.0-beta.4 <3.0.0"
},
"devDependencies": {
"@sinclair/typebox": "^0.34.48",
- "elysia": "^1.2.0",
+ "elysia": "npm:elysia@latest",
+ "elysia-next": "npm:elysia@next",
+ "exact-mirror": "^1.2.4",
"file-type": "^22.0.0",
"tsup": "^8.5.1",
- "typescript": "^5.5.4"
+ "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..78f2804
--- /dev/null
+++ b/packages/elysia/scripts/patch-next-declarations.mjs
@@ -0,0 +1,14 @@
+import { readFile, writeFile } from 'node:fs/promises';
+
+await Promise.all(
+ ['dist/next.d.ts', 'dist/next.d.cts'].map(async (path) => {
+ const declaration = await readFile(path, 'utf8');
+ const patched = declaration.replaceAll('elysia-next', 'elysia');
+
+ if (patched === declaration) {
+ throw new Error(`Expected ${path} to reference the elysia-next development alias`);
+ }
+
+ await writeFile(path, patched);
+ })
+);
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..0e7c5b4
--- /dev/null
+++ b/packages/elysia/src/plugin-next.ts
@@ -0,0 +1,218 @@
+import type { ClientOptions } from '@logtide/types';
+import type { Scope, SpanEvent } from '@logtide/core';
+import {
+ hub,
+ ConsoleIntegration,
+ GlobalErrorIntegration,
+ generateTraceId,
+ parseTraceparent,
+ createTraceparent,
+} from '@logtide/core';
+import Elysia, { ElysiaStatus, StatusMap } from 'elysia-next';
+
+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)])
+ ),
+ },
+ }));
+}
+
+function statusFrom(value: unknown): number | undefined {
+ if (typeof value === 'number') return value;
+
+ if (typeof value === 'string' && value in StatusMap) {
+ return StatusMap[value as keyof typeof StatusMap];
+ }
+
+ if (value instanceof Response || value instanceof ElysiaStatus) {
+ return value.status;
+ }
+
+ return undefined;
+}
+
+/**
+ * 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) {
+ hub.init({
+ service: 'elysia',
+ ...options,
+ integrations: [
+ new ConsoleIntegration(),
+ new GlobalErrorIntegration(),
+ ...(options.integrations ?? []),
+ ],
+ });
+
+ const spanMap = new WeakMap();
+
+ return new Elysia({ name: '@logtide/elysia/next' })
+ .request(({ request }) => {
+ const client = hub.getClient();
+ if (!client) return;
+
+ // Extract trace context
+ 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;
+
+ // Collect start-time attributes
+ 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;
+
+ // Capture startTime BEFORE adding the breadcrumb
+ const startTime = Date.now();
+
+ scope.addBreadcrumb({
+ type: 'http',
+ category: 'request',
+ message: `${method} ${pathname}`,
+ timestamp: Date.now(),
+ data: {
+ method,
+ url: request.url,
+ ...(userAgent ? { userAgent } : {}),
+ },
+ });
+
+ spanMap.set(request, {
+ spanId: span.spanId,
+ scope,
+ traceId,
+ startTime,
+ finalized: false,
+ errored: false,
+ });
+ })
+ .afterHandle(({ request, set }) => {
+ const ctx = spanMap.get(request);
+ if (!ctx) return;
+
+ // This must happen before response mapping; afterResponse is too late to mutate headers.
+ set.headers.traceparent = createTraceparent(ctx.traceId, ctx.spanId, true);
+ })
+ .error(({ request, error, set }) => {
+ const client = hub.getClient();
+ const ctx = spanMap.get(request);
+ if (!client) return;
+
+ if (ctx) {
+ ctx.errored = true;
+ set.headers.traceparent = createTraceparent(ctx.traceId, ctx.spanId, true);
+
+ client.captureError(error, {
+ 'http.url': request.url,
+ 'http.method': request.method,
+ }, ctx.scope);
+ } else {
+ client.captureError(error, {
+ 'http.url': request.url,
+ 'http.method': request.method,
+ });
+ }
+ })
+ .afterResponse(({ request, responseValue, set }) => {
+ const client = hub.getClient();
+ const ctx = spanMap.get(request);
+ if (!client || !ctx || ctx.finalized) return;
+
+ const status = statusFrom(set.status) ?? statusFrom(responseValue) ?? 200;
+ const { scope, spanId, startTime } = ctx;
+ const url = new URL(request.url);
+ const pathname = url.pathname;
+ const method = request.method;
+ const durationMs = Date.now() - startTime;
+
+ scope.addBreadcrumb({
+ type: 'http',
+ category: 'response',
+ message: `${status} ${method} ${pathname}`,
+ level: ctx.errored || status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info',
+ timestamp: Date.now(),
+ data: { status, duration_ms: durationMs },
+ });
+
+ const events: SpanEvent[] = breadcrumbsToEvents(scope);
+ const spanStatus = ctx.errored || status >= 500 ? 'error' : 'ok';
+
+ client.finishSpan(spanId, spanStatus, {
+ extraAttributes: {
+ 'http.status_code': status,
+ 'duration_ms': durationMs,
+ },
+ events,
+ });
+ ctx.finalized = true;
+ })
+ .as('global');
+}
diff --git a/packages/elysia/tests/plugin-next.test.ts b/packages/elysia/tests/plugin-next.test.ts
new file mode 100644
index 0000000..8eb75bd
--- /dev/null
+++ b/packages/elysia/tests/plugin-next.test.ts
@@ -0,0 +1,434 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import Elysia, { NotFound, status } from 'elysia-next';
+import { logtide } from '../src/plugin-next';
+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() {},
+ };
+}
+
+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 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 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('should not treat ordinary payload status fields as HTTP statuses', async () => {
+ const app = new Elysia()
+ .use(logtide({
+ dsn: 'https://lp_key@api.logtide.dev/proj',
+ service: 'elysia-test',
+ transport,
+ }))
+ .get('/status-payload', () => ({ status: 503, healthy: true }));
+
+ 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('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 use the status carried by Elysia 2 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$/
+ );
+ 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('error');
+ });
+
+ 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');
+ });
+});
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..f0d8f0a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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@<1.4.27: 1.4.29
h3: '>=1.15.9'
immutable: '>=5.1.5'
lodash: '>=4.18.1'
@@ -154,17 +154,23 @@ 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@latest
+ version: 1.4.29(@sinclair/typebox@0.34.48)(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@next
+ version: elysia@2.0.0-beta.4(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)
typescript:
- specifier: ^5.5.4
- version: 5.9.3
+ specifier: ^5.7.0
+ version: 5.8.3
packages/express:
dependencies:
@@ -395,8 +401,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.29
+ version: 1.4.29(@sinclair/typebox@0.34.48)(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 +414,31 @@ 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@next
+ version: 2.0.0-beta.4(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)
+ devDependencies:
+ 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':
@@ -4327,6 +4358,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 +4413,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 +4428,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 +4596,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 +5536,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 +7214,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 +7309,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 +7412,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:
@@ -11846,6 +11904,8 @@ snapshots:
detect-node@2.1.0: {}
+ deuri@3.0.0: {}
+
devalue@5.7.0: {}
diff@8.0.3: {}
@@ -11894,11 +11954,23 @@ 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)(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: 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:
+ typescript: 5.8.3
+
+ elysia@1.4.29(@sinclair/typebox@0.34.48)(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: 1.2.4(typebox@1.3.15)
fast-decode-uri-component: 1.0.1
file-type: 22.0.0
memoirist: 0.4.0
@@ -11906,6 +11978,16 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
+ elysia@2.0.0-beta.4(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:
+ openapi-types: 12.1.3
+ typescript: 5.8.3
+
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -12043,9 +12125,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:
@@ -12070,7 +12152,7 @@ snapshots:
body-parser: 1.20.4
content-disposition: 0.5.4
content-type: 1.0.5
- cookie: 0.7.2
+ cookie: 1.1.1
cookie-signature: 1.0.7
debug: 2.6.9
depd: 2.0.0
@@ -13099,6 +13181,8 @@ snapshots:
memoirist@0.4.0: {}
+ memoirist@1.2.2: {}
+
merge-descriptors@1.0.3: {}
merge-descriptors@2.0.0: {}
@@ -15237,6 +15321,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..36aeeee
--- /dev/null
+++ b/test-apps/elysia-v2/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "test-app-elysia-v2",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "test": "vitest run",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@logtide/elysia": "workspace:*",
+ "@logtide/core": "workspace:*",
+ "elysia": "npm:elysia@next",
+ "exact-mirror": "^1.2.4"
+ },
+ "devDependencies": {
+ "logtide-mock-server": "workspace:*",
+ "vitest": "^3.0.0",
+ "typescript": "^5.7.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..534368c
--- /dev/null
+++ b/test-apps/elysia-v2/tests/smoke.test.ts
@@ -0,0 +1,58 @@
+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 { createApp } from '../src/app.js';
+
+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 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/tsconfig.json b/test-apps/elysia-v2/tsconfig.json
new file mode 100644
index 0000000..efe8373
--- /dev/null
+++ b/test-apps/elysia-v2/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "."
+ },
+ "include": ["src/**/*"]
+}
diff --git a/test-apps/elysia-v2/vitest.config.ts b/test-apps/elysia-v2/vitest.config.ts
new file mode 100644
index 0000000..5ce6cf1
--- /dev/null
+++ b/test-apps/elysia-v2/vitest.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ resolve: {
+ dedupe: ['elysia'],
+ },
+ test: {
+ globals: false,
+ environment: 'node',
+ include: ['tests/**/*.test.ts'],
+ testTimeout: 30_000,
+ },
+});